lib/hypothesis/src/properties/database.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const hypothesis = @import("hypothesis");
  3 
  4 const Allocator = std.mem.Allocator;
  5 const ConjectureData = hypothesis.ConjectureData;
  6 const ChoiceNode = hypothesis.ChoiceNode;
  7 const ReplayCursor = hypothesis.database.ReplayCursor;
  8 const Sha256 = std.crypto.hash.sha2.Sha256;
  9 
 10 const maximum_failures: usize = 12;
 11 const maximum_noise: usize = 12;
 12 const digest_text_bytes: usize = Sha256.digest_length * 2;
 13 const failure_extension = ".jhyp";
 14 const failure_name_bytes: usize = digest_text_bytes + failure_extension.len;
 15 const FailureName = [failure_name_bytes]u8;
 16 
 17 const ModelEntry = struct {
 18     name: FailureName,
 19     value: u64,
 20     valid: bool = true,
 21 };
 22 
 23 const CursorModelProperty = struct {
 24     pub fn property(data: *ConjectureData, allocator: Allocator) !void {
 25         var tmp = std.testing.tmpDir(.{});
 26         defer tmp.cleanup();
 27         const path = try tmp.dir.realPathFileAlloc(std.Options.debug_io, ".", allocator);
 28         defer allocator.free(path);
 29         const count: usize = @intCast(try data.drawInteger(1, maximum_failures, 1));
 30         var blocks: [maximum_failures]u8 = undefined;
 31         for (blocks[0..count]) |*byte| {
 32             byte.* = @intCast(try data.drawInteger(0, 255, 0));
 33         }
 34         try saveFailures(allocator, path, blocks[0..count]);
 35         var model: [maximum_failures]ModelEntry = undefined;
 36         try inspectModel(allocator, tmp.dir, model[0..count]);
 37         std.mem.sort(ModelEntry, model[0..count], {}, modelLessThan);
 38         const mask_limit = (@as(u16, 1) << @intCast(count)) - 1;
 39         const corrupt_mask: u16 = @intCast(try data.drawInteger(0, mask_limit, 0));
 40         try corruptSelected(allocator, tmp.dir, model[0..count], corrupt_mask);
 41 
 42         var cursor = try ReplayCursor.init(allocator, .{
 43             .db_path = path,
 44             .namespace = null,
 45             .max_entries = count,
 46             .max_choices = 1,
 47             .max_byte_blocks = 1,
 48         });
 49         defer cursor.deinit(allocator);
 50         cursor.activate();
 51         var expected_index: usize = 0;
 52         while (try cursor.next()) |failure| {
 53             while (expected_index < count and !model[expected_index].valid) {
 54                 expected_index += 1;
 55             }
 56             try std.testing.expect(expected_index < count);
 57             try std.testing.expectEqual(model[expected_index].value, failure.choices[0].value);
 58             expected_index += 1;
 59         }
 60         while (expected_index < count and !model[expected_index].valid) expected_index += 1;
 61         try std.testing.expectEqual(count, expected_index);
 62         try std.testing.expectEqual(
 63             @as(usize, @popCount(corrupt_mask)),
 64             cursor.status().failures_rejected,
 65         );
 66     }
 67 };
 68 
 69 const ScanBudgetProperty = struct {
 70     pub fn property(data: *ConjectureData, allocator: Allocator) !void {
 71         var tmp = std.testing.tmpDir(.{});
 72         defer tmp.cleanup();
 73         const path = try tmp.dir.realPathFileAlloc(std.Options.debug_io, ".", allocator);
 74         defer allocator.free(path);
 75         const failures: usize = @intCast(try data.drawInteger(1, maximum_failures, 1));
 76         const noise: usize = @intCast(try data.drawInteger(0, maximum_noise, 0));
 77         var blocks: [maximum_failures]u8 = undefined;
 78         @memset(blocks[0..failures], 0);
 79         try saveFailures(allocator, path, blocks[0..failures]);
 80         try writeNoise(tmp.dir, noise);
 81         const total = failures + noise;
 82         const budget: usize = @intCast(try data.drawInteger(0, total, 0));
 83         var cursor = try ReplayCursor.init(allocator, .{
 84             .db_path = path,
 85             .namespace = null,
 86             .max_entries = budget,
 87             .max_choices = 1,
 88             .max_byte_blocks = 1,
 89         });
 90         defer cursor.deinit(allocator);
 91         cursor.activate();
 92         var loaded: usize = 0;
 93         while (try cursor.next()) |_| loaded += 1;
 94         const status = cursor.status();
 95         try std.testing.expectEqual(budget, status.entries_scanned);
 96         try std.testing.expect(status.candidate_files <= budget);
 97         try std.testing.expect(loaded <= budget);
 98         try std.testing.expectEqual(budget > 0, status.scan_budget_saturated);
 99     }
100 };
101 
102 fn saveFailures(allocator: Allocator, path: []const u8, blocks: []const u8) !void {
103     for (blocks, 0..) |byte, index| {
104         const choice = ChoiceNode{
105             .kind = .integer,
106             .value = index + 1,
107             .min = 0,
108             .max = maximum_failures,
109         };
110         try hypothesis.database.saveFailure(allocator, path, null, &.{choice}, &.{byte});
111     }
112 }
113 
114 fn inspectModel(allocator: Allocator, dir: std.Io.Dir, model: []ModelEntry) !void {
115     var directory = try dir.openDir(std.Options.debug_io, ".", .{ .iterate = true });
116     defer directory.close(std.Options.debug_io);
117     var iterator = directory.iterate();
118     var count: usize = 0;
119     while (try iterator.next(std.Options.debug_io)) |entry| {
120         if (entry.kind != .file or entry.name.len != failure_name_bytes) continue;
121         const encoded = try directory.readFileAlloc(
122             std.Options.debug_io,
123             entry.name,
124             allocator,
125             .limited(1024),
126         );
127         defer allocator.free(encoded);
128         var name: FailureName = undefined;
129         @memcpy(&name, entry.name);
130         model[count] = .{
131             .name = name,
132             .value = std.mem.readInt(u64, encoded[24..32], .little),
133         };
134         count += 1;
135     }
136     try std.testing.expectEqual(model.len, count);
137 }
138 
139 fn corruptSelected(
140     allocator: Allocator,
141     dir: std.Io.Dir,
142     model: []ModelEntry,
143     mask: u16,
144 ) !void {
145     for (model, 0..) |*entry, index| {
146         if (mask & (@as(u16, 1) << @intCast(index)) == 0) continue;
147         const encoded = try dir.readFileAlloc(
148             std.Options.debug_io,
149             &entry.name,
150             allocator,
151             .limited(1024),
152         );
153         defer allocator.free(encoded);
154         encoded[0] ^= 0xff;
155         try dir.writeFile(std.Options.debug_io, .{
156             .sub_path = &entry.name,
157             .data = encoded,
158         });
159         entry.valid = false;
160     }
161 }
162 
163 fn writeNoise(dir: std.Io.Dir, count: usize) !void {
164     for (0..count) |index| {
165         var name_buffer: [32]u8 = undefined;
166         const name = try std.fmt.bufPrint(&name_buffer, "noise-{d}", .{index});
167         try dir.writeFile(std.Options.debug_io, .{ .sub_path = name, .data = "noise" });
168     }
169 }
170 
171 fn modelLessThan(_: void, left: ModelEntry, right: ModelEntry) bool {
172     return std.mem.order(u8, &left.name, &right.name) == .lt;
173 }
174 
175 test "fuzz: replay cursor matches sorted corruption model" {
176     try hypothesis.checkNamed(CursorModelProperty, "hypothesis-replay-cursor-model", .{
177         .max_examples = 32,
178         .max_replays = 0,
179         .target_examples = 0,
180         .report_failure = false,
181         .seed = 0x4859_5043_5552_534f,
182     });
183 }
184 
185 test "fuzz: every directory entry consumes replay scan budget" {
186     try hypothesis.checkNamed(ScanBudgetProperty, "hypothesis-replay-scan-budget", .{
187         .max_examples = 32,
188         .max_replays = 0,
189         .target_examples = 0,
190         .report_failure = false,
191         .seed = 0x4859_5053_4341_4e42,
192     });
193 }