Something went wrong. Try again.
Reads and writes the Ogg container format in Zig: pages, packets, grouping and chaining, with a page layer that allocates nothing.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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; }};