150 lines
6.4 KiB
Zig
150 lines
6.4 KiB
Zig
//! 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),
|
||
};
|