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 }