Something went wrong. Try again.
A Zig library and command line tool for the classic Unix uuencode format.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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), });}