Files
nxdns/tests/fuzz/regex_fuzz.zig
T

150 lines
6.4 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fuzz target for the regex engine (`src/filter/regex.zig`, milestone-21
//! ruling 10).
//!
//! The contract: any byte string is a legal pattern, so `compile` may reject it
//! however it likes but must return — never panic, never loop forever, never
//! read out of bounds. Where it returns a program the target then checks what
//! the filter is entitled to rely on:
//!
//! - the program obeys ruling 5's limits: it is non-empty, at most
//! `max_program_len` instructions, and it came from a pattern of at most
//! `max_pattern_len` bytes;
//! - `matches` terminates on any input, and the VM's step count never exceeds
//! program length × (input length + 1), which is the linearity claim the
//! whole design rests on;
//! - compile-then-match is deterministic: the same pattern compiled twice
//! gives the same program length and the same verdict on the same input,
//! and two runs of one program agree step for step.
//!
//! `regex.zig` imports only `std`, so this target's module roots directly at
//! that file — no aggregator needed.
//!
//! Runner semantics: under a plain `zig build test` the target runs once per
//! corpus entry plus once on empty input, which makes the corpus a regression
//! suite. `zig build test --fuzz=<n>` gives it `n` generated inputs.
const std = @import("std");
const regex = @import("regex");
const smith_encode = @import("smith_encode.zig");
const sliceInput = smith_encode.sliceInput;
const pairInput = smith_encode.pairInput;
const Smith = std.testing.Smith;
/// Twice `regex.max_pattern_len`, so `error.PatternTooLong` is reachable rather
/// than the only thing the target ever sees.
const max_pattern = 2 * regex.max_pattern_len;
/// Past the 253 bytes of the longest text name, which is the longest input the
/// filter ever hands the engine.
const max_name = 512;
/// `Smith` entity ids: the pattern and the name it is matched against.
const pattern_hash: u32 = 1;
const name_hash: u32 = 2;
const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus };
test "fuzz regex.compile and regex.matches" {
try std.testing.fuzz({}, regexTarget, fuzz_options);
}
fn regexTarget(_: void, smith: *Smith) anyerror!void {
var pattern_buf: [max_pattern]u8 = undefined;
var name_buf: [max_name]u8 = undefined;
const pattern = pattern_buf[0..smith.sliceWithHash(&pattern_buf, pattern_hash)];
const input = name_buf[0..smith.sliceWithHash(&name_buf, name_hash)];
var prog = (try compileOnce(pattern)) orelse return;
defer prog.deinit(std.testing.allocator);
// Ruling 5's limits, read off the program the compiler agreed to build.
try std.testing.expect(pattern.len <= regex.max_pattern_len);
try std.testing.expect(prog.insts.len > 0);
try std.testing.expect(prog.insts.len <= regex.max_program_len);
const first = regex.run(&prog, input);
try expectLinear(first, prog.insts.len, input.len);
try std.testing.expectEqual(first.matched, regex.matches(&prog, input));
// The empty name is the cheapest way to reach the position-zero closure with
// no consuming step behind it, so every pattern is run against it too.
try expectLinear(regex.run(&prog, ""), prog.insts.len, 0);
const again = regex.run(&prog, input);
try std.testing.expectEqual(first.matched, again.matched);
try std.testing.expectEqual(first.steps, again.steps);
// Compiling is a pure function of the pattern bytes: the second program
// matches the first instruction for instruction and answers the same.
var second = (try compileOnce(pattern)) orelse return error.TestSecondCompileFailed;
defer second.deinit(std.testing.allocator);
try std.testing.expectEqual(prog.insts.len, second.insts.len);
try std.testing.expectEqual(prog.classes.len, second.classes.len);
try std.testing.expectEqual(first.matched, regex.matches(&second, input));
}
/// One compile, or null when the engine rejected the pattern. Every member of
/// `regex.Error` is a legitimate rejection: unparsable syntax, a pattern past
/// the byte limit, a program past the instruction limit, and an allocator that
/// ran out.
fn compileOnce(pattern: []const u8) anyerror!?regex.Program {
return regex.compile(std.testing.allocator, pattern) catch |err| switch (err) {
error.OutOfMemory,
error.BadPattern,
error.PatternTooLong,
error.PatternTooComplex,
=> null,
};
}
fn expectLinear(result: regex.Run, program_len: usize, input_len: usize) !void {
try std.testing.expect(result.steps <= program_len * (input_len + 1));
}
// ---------------------------------------------------------------------------
// corpus
// ---------------------------------------------------------------------------
//
// `Smith` does not consume a corpus entry as raw input, so every entry below
// goes through the `smith_encode.zig` encoders. An entry that carries only the
// pattern leaves the name empty, which is the position-zero closure on its own.
/// The backtracker killers: exponential for a backtracking engine, linear here.
const nested_plus = "(a+)+b";
const nested_alternation = "^(a|aa)+$";
const nested_star = "^(a*)*(b*)*$";
/// An epsilon cycle: the loop body consumes nothing, so only the VM's
/// one-admission-per-position rule ends the walk.
const empty_loop = "^((a*)*)*$";
/// A name long enough that a quadratic step count would show against the bound.
const long_name = "a" ** 252 ++ "X";
/// Emits nothing at all, so only the emitter's visit budget ends the compile.
const empty_body_blowup = "((((x{0}){900}){900}){900}){900}";
const corpus = [_][]const u8{
pairInput(nested_plus, "a" ** 20 ++ "X"),
pairInput(nested_alternation, long_name),
pairInput(nested_star, long_name),
pairInput(empty_loop, long_name),
pairInput("^ad[0-9]+-", "ad42-serve.example.com"),
pairInput("^(ads|track)\\.example\\.(com|net)$", "track.example.net"),
pairInput("[^.]+\\.doubleclick\\.net$", "static.doubleclick.net"),
pairInput("^\\w{1,8}\\.\\d{2}\\.example$", "ads_42.13.example"),
// Every rejection path, so the corpus replays them rather than waiting on a
// discovery: bad syntax, an over-long pattern, an over-large program, and a
// compile that only the visit budget stops.
sliceInput("(a"),
sliceInput("[z-a]"),
sliceInput("a{2,1}"),
sliceInput("ads\\"),
sliceInput("(?:ab)"),
sliceInput("a+?"),
sliceInput("a" ** (regex.max_pattern_len + 1)),
sliceInput("(abcd){400}"),
sliceInput(empty_body_blowup),
};