// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Randomized testing. //! //! The properties worth checking are stated once, as the `check*` functions //! below, and then driven from two directions: a pseudorandom sweep that runs //! under a plain `zig build test`, and `std.testing.fuzz` targets that a //! coverage-guided fuzzer can drive with `zig build test --fuzz`. //! //! The sweep is not redundant with the fuzz targets: a plain `zig build test` //! runs each fuzz target without fuzzing it, so the sweep is what exercises //! these properties on every build. It is sized to cover a useful //! spread of inputs while staying fast enough for that. const std = @import("std"); const Io = std.Io; const testing = std.testing; const Smith = std.testing.Smith; const Random = std.Random; const zuucode = @import("root.zig"); // ---------------------------------------------------------------- properties /// Encoding must be independent of how the payload is handed over, and must /// round trip. `chunks` gives the write sizes the incremental encoder is /// driven with, cycled until the payload runs out. fn checkRoundTrip( gpa: std.mem.Allocator, payload: []const u8, options: zuucode.EncodeOptions, chunks: []const u32, ) !void { const one_shot = try zuucode.encodeAlloc(gpa, payload, options); defer gpa.free(one_shot); try testing.expectEqual(zuucode.encodedLen(payload.len, options), one_shot.len); var allocating: Io.Writer.Allocating = .init(gpa); defer allocating.deinit(); var encoder: zuucode.Encoder = .init(&allocating.writer, options); var offset: usize = 0; var next: usize = 0; while (offset < payload.len) { const want = if (chunks.len == 0) payload.len else chunks[next % chunks.len]; next += 1; const chunk = @min(@max(want, 1), payload.len - offset); try encoder.write(payload[offset..][0..chunk]); offset += chunk; } try encoder.flush(); try testing.expectEqualStrings(one_shot, allocating.written()); const decoded = try zuucode.decodeAlloc(gpa, one_shot, .{ .require_terminator = true }); defer gpa.free(decoded); try testing.expectEqualSlices(u8, payload, decoded); } /// The buffered and streaming decoders must reach the same verdict on the /// same bytes, and `decodedLen` must predict exactly what `decode` produces. fn checkDecodeAgreement( gpa: std.mem.Allocator, text: []const u8, options: zuucode.DecodeOptions, ) !void { var streamed: Io.Writer.Allocating = .init(gpa); defer streamed.deinit(); var reader: Io.Reader = .fixed(text); const stream_result = zuucode.decodeStream(&reader, &streamed.writer, options); if (zuucode.decodeAlloc(gpa, text, options)) |decoded| { defer gpa.free(decoded); // decodeAlloc sizes its buffer from decodedLen, so this also pins down // that both agree about where the body ends. try testing.expectEqual(decoded.len, try zuucode.decodedLen(text, options)); try stream_result; try testing.expectEqualSlices(u8, decoded, streamed.written()); } else |_| { try testing.expect(std.meta.isError(stream_result)); } } /// Whatever a line claims, `decodeLine` must write exactly that many bytes /// and stay inside the destination it was given. fn checkDecodeLine(line: []const u8, options: zuucode.DecodeOptions) !void { if (line.len == 0) return; const claimed: usize = zuucode.decodeChar(line[0]) catch return; if (claimed > zuucode.bytes_per_line) return; // Sized to exactly what the length character claims, so one byte of // overrun trips a bounds check instead of going unnoticed. var dest: [zuucode.bytes_per_line]u8 = undefined; const written = zuucode.decodeLine(dest[0..claimed], line, options) catch return; try testing.expectEqual(claimed, written); } /// Arbitrary text must be rejected rather than mishandled, and anything the /// decoder does accept must survive being encoded and decoded again. fn checkArbitrary( gpa: std.mem.Allocator, text: []const u8, options: zuucode.DecodeOptions, ) !void { if (zuucode.decodeAlloc(gpa, text, options)) |decoded| { defer gpa.free(decoded); const reencoded = try zuucode.encodeAlloc(gpa, decoded, .{}); defer gpa.free(reencoded); const again = try zuucode.decodeAlloc(gpa, reencoded, .{ .require_terminator = true }); defer gpa.free(again); try testing.expectEqualSlices(u8, decoded, again); } else |_| {} if (zuucode.decodeFileAlloc(gpa, text, options)) |file| { gpa.free(file.data); } else |_| {} } /// The dispatched entry points must produce exactly what the scalar reference /// produces. This is the property the CPU guard rests on: whichever path a /// target takes, the bytes are the same. fn checkScalarAgreement(payload: []const u8, options: zuucode.EncodeOptions) !void { var dispatched: [zuucode.chars_per_line]u8 = undefined; var reference: [zuucode.chars_per_line]u8 = undefined; const line = payload[0..@min(payload.len, zuucode.bytes_per_line)]; try testing.expectEqualStrings( zuucode.encodeLineScalar(&reference, line, options), zuucode.encodeLine(&dispatched, line, options), ); var decoded: [zuucode.bytes_per_line]u8 = undefined; var decoded_reference: [zuucode.bytes_per_line]u8 = undefined; const encoded = zuucode.encodeLineScalar(&reference, line, options); const a = zuucode.decodeLine(&decoded, encoded, .{}); const b = zuucode.decodeLineScalar(&decoded_reference, encoded, .{}); if (a) |count| { try testing.expectEqual(try b, count); try testing.expectEqualSlices(u8, decoded_reference[0..count], decoded[0..count]); } else |err| { try testing.expectError(err, b); } } // ----------------------------------------------------------- input generation /// Payloads are drawn from a few shapes rather than uniform noise, because the /// encoder's interesting cases are runs of zero bytes (which are what the two /// charsets disagree about) and lengths either side of a 45 byte line. const Shape = enum { noise, zeros, ones, ascii, sparse }; fn fillPayload(random: Random, buf: []u8, shape: Shape) void { switch (shape) { .noise => random.bytes(buf), .zeros => @memset(buf, 0), .ones => @memset(buf, 0xff), .ascii => for (buf) |*byte| { byte.* = random.intRangeAtMost(u8, ' ', '~'); }, // Mostly zeros, so encoded lines are dominated by the zero character // with the occasional real byte among them. .sparse => for (buf) |*byte| { byte.* = if (random.uintLessThan(u8, 8) == 0) random.int(u8) else 0; }, } } fn randomEncodeOptions(random: Random) zuucode.EncodeOptions { return .{ .charset = random.enumValue(zuucode.Charset), .line_ending = random.enumValue(zuucode.LineEnding), }; } fn randomDecodeOptions(random: Random) zuucode.DecodeOptions { return .{ .pad_truncated_lines = random.boolean(), .require_terminator = random.boolean(), }; } /// Damages `text` the way a mail transport, a careless editor, or a truncated /// download would. fn corrupt(gpa: std.mem.Allocator, text: *std.ArrayList(u8), random: Random) !void { const rounds = random.uintAtMost(usize, 8); for (0..rounds) |_| { if (text.items.len == 0) return; const at = random.uintLessThan(usize, text.items.len); switch (random.enumValue(enum { replace, truncate, insert, strip_space, swap_eol })) { .replace => text.items[at] = random.int(u8), .truncate => text.shrinkRetainingCapacity(at), .insert => try text.insert(gpa, at, random.int(u8)), // The damage the decoder is specifically built to survive. .strip_space => if (text.items[at] == ' ') { _ = text.orderedRemove(at); }, .swap_eol => if (text.items[at] == '\n') { try text.insert(gpa, at, '\r'); }, } } } /// Text that is plausible enough to reach the decoder's interesting paths: /// mostly characters from the uuencode alphabet, broken into short lines. fn plausibleText(gpa: std.mem.Allocator, random: Random) ![]u8 { var text: std.ArrayList(u8) = .empty; errdefer text.deinit(gpa); const lines = random.uintAtMost(usize, 6); for (0..lines) |_| { const len = random.uintAtMost(usize, 70); for (0..len) |_| { const char = if (random.uintLessThan(u8, 16) == 0) random.int(u8) else random.intRangeAtMost(u8, ' ', '`'); try text.append(gpa, char); } if (random.uintLessThan(u8, 8) == 0) try text.appendSlice(gpa, "end"); try text.append(gpa, '\n'); } return text.toOwnedSlice(gpa); } // ------------------------------------------------------------ the sweep test "randomized sweep" { const gpa = testing.allocator; var prng: Random.DefaultPrng = .init(0x2075_5543_4f_4445); const random = prng.random(); var payload_buf: [600]u8 = undefined; var chunk_buf: [8]u32 = undefined; var line_buf: [128]u8 = undefined; for (0..1500) |_| { // Lengths cluster around the line boundary, where the padding and // terminator logic lives, but sometimes span several lines. const payload_len = switch (random.uintLessThan(u8, 4)) { 0 => random.uintAtMost(usize, 3), 1, 2 => random.uintAtMost(usize, 2 * zuucode.bytes_per_line + 2), else => random.uintAtMost(usize, payload_buf.len), }; const payload = payload_buf[0..payload_len]; fillPayload(random, payload, random.enumValue(Shape)); for (&chunk_buf) |*chunk| chunk.* = random.intRangeAtMost(u32, 1, 100); const chunks = chunk_buf[0..random.intRangeAtMost(usize, 1, chunk_buf.len)]; const encode_options = randomEncodeOptions(random); try checkRoundTrip(gpa, payload, encode_options, chunks); try checkScalarAgreement(payload, encode_options); // Encode a file, damage it, and hold the decoders to the same verdict. const encoded = try zuucode.encodeFileAlloc( gpa, .{ .name = "sweep", .mode = 0o644 }, payload, encode_options, ); defer gpa.free(encoded); var text: std.ArrayList(u8) = .empty; defer text.deinit(gpa); try text.appendSlice(gpa, encoded); try corrupt(gpa, &text, random); const decode_options = randomDecodeOptions(random); try checkDecodeAgreement(gpa, text.items, decode_options); try checkArbitrary(gpa, text.items, decode_options); // Free-standing text that never was a valid encoding. const plausible = try plausibleText(gpa, random); defer gpa.free(plausible); try checkDecodeAgreement(gpa, plausible, decode_options); try checkArbitrary(gpa, plausible, decode_options); const line = line_buf[0..random.uintAtMost(usize, line_buf.len)]; for (line) |*char| char.* = random.intRangeAtMost(u8, ' ', '`'); try checkDecodeLine(line, decode_options); } } // ------------------------------------------------------------ fuzz targets // // These drive the same properties from a coverage-guided fuzzer. They run once // each under a plain `zig build test`, so they cannot rot, but they only do // real work once `zig build test --fuzz` builds again. test "fuzz round trip" { try testing.fuzz({}, fuzzRoundTrip, .{}); } fn fuzzRoundTrip(context: void, smith: *Smith) !void { _ = context; const gpa = testing.allocator; var payload: std.ArrayList(u8) = .empty; defer payload.deinit(gpa); while (!smith.eos()) { const slice = try payload.addManyAsSlice(gpa, smith.value(u8)); smith.bytes(slice); } var chunks: [4]u32 = undefined; for (&chunks) |*chunk| chunk.* = smith.valueRangeAtMost(u32, 1, 128); try checkRoundTrip(gpa, payload.items, .{ .charset = smith.value(zuucode.Charset), .line_ending = smith.value(zuucode.LineEnding), }, &chunks); } test "fuzz decoders agree" { try testing.fuzz({}, fuzzDecodeAgreement, .{}); } fn fuzzDecodeAgreement(context: void, smith: *Smith) !void { _ = context; const gpa = testing.allocator; var text_buf: [1024]u8 = undefined; const text = text_buf[0..smith.slice(&text_buf)]; const options: zuucode.DecodeOptions = .{ .pad_truncated_lines = smith.value(bool), .require_terminator = smith.value(bool), }; try checkDecodeAgreement(gpa, text, options); try checkArbitrary(gpa, text, options); } test "fuzz decodeLine" { try testing.fuzz({}, fuzzDecodeLine, .{}); } fn fuzzDecodeLine(context: void, smith: *Smith) !void { _ = context; var line_buf: [128]u8 = undefined; const line = line_buf[0..smith.slice(&line_buf)]; try checkDecodeLine(line, .{ .pad_truncated_lines = smith.value(bool) }); } test "fuzz scalar and vector paths agree" { try testing.fuzz({}, fuzzScalarAgreement, .{}); } fn fuzzScalarAgreement(context: void, smith: *Smith) !void { _ = context; var buf: [zuucode.bytes_per_line]u8 = undefined; const payload = buf[0..smith.slice(&buf)]; try checkScalarAgreement(payload, .{ .charset = smith.value(zuucode.Charset), .line_ending = smith.value(zuucode.LineEnding), }); }