Something went wrong. Try again.
A sans-I/O Zig library that reads and writes DEC sixel graphics as z2d surfaces.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Fuzz targets: what the decoder and the relay must do with input nobody//! wrote, and what the encoder must do with any surface at all.//!//! zig build test --fuzz # until interrupted//! zig build test --fuzz=1M # a bounded run//!//! Without `--fuzz` each target runs once over its corpus, which is every//! libsixel fixture and a handful of streams with the awkward shapes in them,//! so `zig build test` checks the same properties on known inputs.//!//! The decode and relay targets read their input with `Smith.slice`, which//! takes a little-endian u32 length and then that many bytes; a length//! larger than the buffer gives an empty slice rather than a short one. The//! corpus is laid out that way, by `entry`.
const std = @import("std");const Io = std.Io;const testing = std.testing;const Smith = testing.Smith;const sixel = @import("sixel");const z2d = @import("z2d");const fixtures = @import("fixtures.zig").fixtures;
/// Small, so that an input asking for a huge image is clipped quickly and the/// fuzzer spends its time on the parser rather than on memset.const limits: sixel.Limits = .{ .max_width = 512, .max_height = 512, .max_pixels = 64 * 1024 };
const max_input = 16 * 1024;
fn entry(comptime data: []const u8) []const u8 { comptime { std.debug.assert(data.len <= max_input); var len: [4]u8 = undefined; std.mem.writeInt(u32, &len, data.len, .little); return &len ++ data; }}
const streams = [_][]const u8{ "text\x1bPq#1;2;100;0;0!10~-~\x1b\\more", "\x1b[31m\x1bP0;1;0q\"1;1;4;4#3@\x1b\\\x1bP$q\"q\x1b\\\x1b]0;t\x07", "\x1bPq~\x18a\x1bPq~~\x1b[Kb\x1bPq~~~\x1ac\x1bPq~~~~", "\x1b\x1b\x1bPq@\x1b\x1bPq@\x1b\\\x1bP", "\x1bPq!99999999999~#99999;2;999;999;999-!4000000000@$-----\x1b\\", "\x1bPq\"0;0;99999;99999#1;1;999;50;100~\x1b\\",};
const corpus = blk: { var list: [fixtures.len + streams.len][]const u8 = undefined; for (fixtures, 0..) |f, i| list[i] = entry(f.six); for (streams, fixtures.len..) |s, i| list[i] = entry(s); break :blk list;};
fn decodeOne(_: void, smith: *Smith) anyerror!void { var buf: [max_input]u8 = undefined; const input = buf[0..smith.slice(&buf)]; var img = sixel.decode(testing.allocator, input, .{ .limits = limits }) catch |err| switch (err) { error.EmptyImage, error.InvalidIntroducer => null, error.OutOfMemory => return err, }; defer if (img) |*i| i.deinit(testing.allocator); if (img) |i| { try testing.expect(i.width() <= limits.max_width); try testing.expect(i.height() <= limits.max_height); try testing.expect(@as(u64, i.width()) * i.height() <= limits.max_pixels); }
// The parser keeps its state between calls, so feeding it the same bytes // in pieces, split wherever the fuzzer likes, gives the same image as // feeding them all at once. var whole = try parse(input, &.{}); defer if (whole) |*i| i.deinit(testing.allocator); var splits: [16]u16 = undefined; var n: usize = 0; while (n < splits.len and !smith.eosWeightedSimple(1, 3)) : (n += 1) { splits[n] = smith.valueRangeAtMost(u16, 1, 256); } var pieces = try parse(input, splits[0..n]); defer if (pieces) |*i| i.deinit(testing.allocator); try testing.expectEqual(whole == null, pieces == null); if (whole) |a| { const b = pieces.?; try testing.expectEqual(a.clipped, b.clipped); try testing.expectEqual(a.aspect, b.aspect); try testing.expectEqual(a.surface.getWidth(), b.surface.getWidth()); try testing.expectEqualSlices(z2d.pixel.RGBA, a.surface.image_surface_rgba.buf, b.surface.image_surface_rgba.buf); }}
/// Runs `input` through a parser as sixel data, in pieces of the given sizes/// and then the rest, stopping where the parser does.fn parse(input: []const u8, sizes: []const u16) !?sixel.Image { var p: sixel.Parser = .init(testing.allocator, .{}, .{ .limits = limits }); defer p.deinit(); var rest = input; for (sizes) |size| { const piece = rest[0..@min(size, rest.len)]; const used = try p.feed(piece); if (used < piece.len) return p.finish(.interrupted); rest = rest[piece.len..]; } _ = try p.feed(rest); return p.finish(.interrupted);}
test "fuzz decode" { try testing.fuzz({}, decodeOne, .{ .corpus = &corpus });}
/// Hands its input over `chunk` bytes per read.const Chunked = struct { input: []const u8, chunk: usize, pos: usize = 0, interface: Io.Reader,
fn stream(r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { const self: *Chunked = @alignCast(@fieldParentPtr("interface", r)); if (self.pos == self.input.len) return error.EndOfStream; const end = @min(self.input.len, self.pos + self.chunk); const n = try w.write(limit.sliceConst(self.input[self.pos..end])); self.pos += n; return n; }};
const Summary = struct { count: usize = 0, sizes: [8][2]u32 = undefined,
pub fn image(self: *Summary, img: sixel.Image) !void { var owned = img; defer owned.deinit(testing.allocator); if (self.count < self.sizes.len) self.sizes[self.count] = .{ owned.width(), owned.height() }; self.count += 1; }};
/// Relays `input` in pieces of `chunk` bytes, and returns what came out.fn relayIn(input: []const u8, chunk: usize, c1: bool, summary: *Summary) ![]u8 { var buf: [sixel.min_buffer_len]u8 = undefined; var reader: Chunked = .{ .input = input, .chunk = chunk, .interface = .{ .vtable = &.{ .stream = Chunked.stream }, .buffer = &buf, .seek = 0, .end = 0 }, }; var out: Io.Writer.Allocating = .init(testing.allocator); errdefer out.deinit(); try sixel.relay(testing.allocator, &reader.interface, &out.writer, summary, .{ .limits = limits, .c1 = c1 }); return out.toOwnedSlice();}
fn relayOne(_: void, smith: *Smith) anyerror!void { var buf: [max_input]u8 = undefined; const input = buf[0..smith.slice(&buf)]; const chunk = smith.valueRangeAtMost(u8, 1, 64); const c1 = smith.value(bool);
// However the input is split, the same text comes out and the same // images are found. var whole: Summary = .{}; const a = try relayIn(input, input.len + 1, c1, &whole); defer testing.allocator.free(a); var split: Summary = .{}; const b = try relayIn(input, chunk, c1, &split); defer testing.allocator.free(b); try testing.expectEqualStrings(a, b); try testing.expectEqual(whole.count, split.count); for (0..@min(whole.count, whole.sizes.len)) |i| try testing.expectEqual(whole.sizes[i], split.sizes[i]);
// Nothing is made up: the output is the input with parts taken out, // so it is never longer, and input with no ESC (or 0x90) comes out // whole. try testing.expect(a.len <= input.len); const has_start = std.mem.findScalar(u8, input, 0x1b) != null or (c1 and std.mem.findScalar(u8, input, 0x90) != null); if (!has_start) try testing.expectEqualStrings(input, a);}
test "fuzz relay" { try testing.fuzz({}, relayOne, .{ .corpus = &corpus });}
fn encodeOne(_: void, smith: *Smith) anyerror!void { const w = smith.valueRangeAtMost(u8, 1, 40); const h = smith.valueRangeAtMost(u8, 1, 40); var surface = try z2d.Surface.init(.image_surface_rgba, testing.allocator, w, h); defer surface.deinit(testing.allocator); // Few distinct values, so that some inputs fit a palette and some do // not, and colors repeat into runs. const levels = [_]u8{ 0, 1, 127, 128, 254, 255 }; for (surface.image_surface_rgba.buf) |*px| { const a = levels[smith.index(levels.len)]; px.* = (z2d.pixel.RGBA{ .r = levels[smith.index(levels.len)], .g = levels[smith.index(levels.len)], .b = levels[smith.index(levels.len)], .a = a, }).multiply(); } const options: sixel.EncodeOptions = .{ .max_colors = smith.valueRangeAtMost(u16, 1, 300), .dither = if (smith.value(bool)) .floyd_steinberg else .none, .alpha_threshold = smith.value(u8), .raster_attributes = smith.value(bool), .c1 = smith.value(bool), }; const bytes = try sixel.encodeAlloc(testing.allocator, &surface, options); defer testing.allocator.free(bytes);
var img = sixel.decode(testing.allocator, bytes, .{}) catch |err| switch (err) { // Nothing drawn and no raster attributes to give a size. error.EmptyImage => return testing.expect(!options.raster_attributes), else => return err, }; defer img.deinit(testing.allocator); try testing.expectEqual(sixel.Image.Termination.st, img.termination); if (!options.raster_attributes) { // Without them the size is only what was drawn, which can be less. try testing.expect(img.width() <= w and img.height() <= h); return; } try testing.expectEqual(@as(u32, w), img.width()); try testing.expectEqual(@as(u32, h), img.height());
// Whether each pixel is drawn never changes. And if the drawn pixels' // colors fit the palette, every one comes back within the rounding to // whole percentages; otherwise it is quantized, and only close. var distinct: std.AutoHashMapUnmanaged(u24, void) = .empty; defer distinct.deinit(testing.allocator); const threshold = @max(options.alpha_threshold, 1); for (surface.image_surface_rgba.buf) |px| { const src = px.demultiply(); if (src.a < threshold) continue; const c = (sixel.palette.Rgb{ .r = src.r, .g = src.g, .b = src.b }).snapped(); try distinct.put(testing.allocator, @as(u24, c.r) << 16 | @as(u24, c.g) << 8 | c.b, {}); } const exact = distinct.count() <= std.math.clamp(options.max_colors, 1, sixel.palette.max_registers); for (surface.image_surface_rgba.buf, img.surface.image_surface_rgba.buf) |px, got| { const src = px.demultiply(); if (src.a < threshold) { try testing.expectEqual(@as(u8, 0), got.a); continue; } try testing.expectEqual(@as(u8, 255), got.a); if (!exact) continue; for ([_]u8{ src.r, src.g, src.b }, [_]u8{ got.r, got.g, got.b }) |a, b| { try testing.expect(@max(a, b) - @min(a, b) <= 2); } }}
test "fuzz encode" { try testing.fuzz({}, encodeOne, .{});}
test "corpus entries are read as written" { // If the layout `entry` writes and the one `Smith.slice` reads ever // drift apart, every target would quietly test the empty string. for (corpus, 0..) |e, i| { var smith: Smith = .{ .in = e }; var buf: [max_input]u8 = undefined; const got = buf[0..smith.slice(&buf)]; const want = if (i < fixtures.len) fixtures[i].six else streams[i - fixtures.len]; try testing.expectEqualStrings(want, got); }}