lib/reticulum/src/properties/transport.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const hypothesis = @import("hypothesis");
3 const reticulum = @import("reticulum");
4
5 const node = reticulum.node;
6 const wire = reticulum.wire;
7 const Blob = node.transport.path.Blob;
8
9 const add_rules_seed: u64 = 0x5236_5041_5448_0001;
10 const rewrites_seed: u64 = 0x5236_5041_5448_0002;
11 const request_parse_seed: u64 = 0x5236_5041_5448_0003;
12 const fixture_faults_seed: u64 = 0x5236_5041_5448_0004;
13 const announces_max: u64 = 12;
14 const week: u64 = 604_800;
15 const delays = [_]u64{ 0, 1, 5, week - 1, week, week + 1 };
16
17 const limits = node.Limits{
18 .interfaces_max = 1,
19 .destinations_max = 1,
20 .known_identities_max = 1,
21 .known_ratchets_max = 1,
22 .receipts_max = 1,
23 .duplicate_hashes_max = 4,
24 .timers_max = 1,
25 .effects_max = 2,
26 .effect_frames_max = 2,
27 .paths_max = 1,
28 .announces_max = 1,
29 .reverse_entries_max = 1,
30 .path_request_tags_max = 1,
31 .inflight_requests_max = 1,
32 .discoveries_max = 1,
33 .links_max = 1,
34 .link_entries_max = 1,
35 };
36 const capacity = node.Capacity.derive(limits) catch unreachable;
37
38 fn settings(seed: u64) hypothesis.Settings {
39 return hypothesis.Settings.quick()
40 .withSeed(seed)
41 .withDatabase("zig-out/hypothesis-failures/reticulum");
42 }
43
44 fn emitted(blob: Blob) u64 {
45 return std.mem.readInt(u40, blob[5..10], .big);
46 }
47
48 const PathModel = struct {
49 known: bool = false,
50 hops: u8 = 0,
51 timestamp: u64 = 0,
52 expires: u64 = 0,
53 blobs: [64]Blob = undefined,
54 len: usize = 0,
55
56 fn holds(self: *const PathModel, blob: Blob) bool {
57 for (self.blobs[0..self.len]) |held| {
58 if (std.mem.eql(u8, &held, &blob)) return true;
59 }
60 return false;
61 }
62
63 fn newest(self: *const PathModel) u64 {
64 var result: u64 = 0;
65 for (self.blobs[0..self.len]) |held| result = @max(result, emitted(held));
66 return result;
67 }
68
69 fn decide(self: *const PathModel, hops: u8, blob: Blob, now: u64) bool {
70 if (!self.known) return true;
71 if (hops <= self.hops) return !self.holds(blob) and emitted(blob) > self.newest();
72 if (now >= self.expires) return !self.holds(blob);
73 if (emitted(blob) > self.newest()) return !self.holds(blob);
74 return false;
75 }
76
77 fn offer(self: *PathModel, hops: u8, blob: Blob, now: u64) bool {
78 if (self.known and now > self.timestamp + week) self.* = .{};
79 if (!self.decide(hops, blob, now)) return false;
80 if (self.len == self.blobs.len) {
81 std.mem.copyForwards(Blob, self.blobs[0 .. self.len - 1], self.blobs[1..self.len]);
82 self.len -= 1;
83 }
84 self.blobs[self.len] = blob;
85 self.len += 1;
86 self.known = true;
87 self.hops = hops;
88 self.timestamp = now;
89 self.expires = now + week;
90 return true;
91 }
92 };
93
94 fn announceFrame(
95 private: *const reticulum.identity.Private,
96 name_hash: [10]u8,
97 destination_hash: [16]u8,
98 blob: Blob,
99 hops: u8,
100 out: *[wire.mtu]u8,
101 ) ![]const u8 {
102 var payload: [reticulum.destination.announce.payload_bytes_max]u8 = undefined;
103 const signed = try reticulum.destination.announce.build(.{
104 .destination_hash = destination_hash,
105 .name_hash = name_hash,
106 .random_hash = blob,
107 }, private, &payload);
108 return wire.encode(.{
109 .ifac = 0,
110 .header = .one,
111 .context_flag = 0,
112 .transport = .broadcast,
113 .destination_type = .single,
114 .packet_type = .announce,
115 .hops = hops,
116 .transport_id = null,
117 .destination = destination_hash,
118 .context = .none,
119 .payload = signed,
120 }, out);
121 }
122
123 fn accepted(effects: []const node.Effect) bool {
124 for (effects) |effect| switch (effect) {
125 .announce_received => return true,
126 else => {},
127 };
128 return false;
129 }
130
131 fn drawBlob(data: *hypothesis.ConjectureData) !Blob {
132 const salt: u8 = @intCast(try data.drawInteger(0, 3, 0));
133 var blob: Blob = @splat(salt);
134 const emission: u40 = @intCast(1_699_999_990 + try data.drawInteger(0, 12, 0));
135 std.mem.writeInt(u40, blob[5..10], emission, .big);
136 return blob;
137 }
138
139 const PathAddRules = struct {
140 pub fn property(data: *hypothesis.ConjectureData, _: std.mem.Allocator) !void {
141 var storage: [capacity.storage_bytes]u8 align(8) = undefined;
142 var owner = try node.Node.init(&storage, limits);
143 owner.activate();
144 defer _ = owner.deinit();
145 try owner.registerCarrier(0, true, null);
146 var private = reticulum.identity.Private.fromBytes(@splat(0x37));
147 defer private.zero();
148 const name_hash: [10]u8 = @splat(0x21);
149 var hasher = reticulum.hash.Hasher.init();
150 hasher.update(&name_hash);
151 hasher.update(&private.hash());
152 const destination_hash = hasher.finalTruncated();
153 var model = PathModel{};
154 var now: u64 = 1_700_000_000;
155 const count: usize = @intCast(try data.drawInteger(1, announces_max, 1));
156 for (0..count) |_| {
157 now += delays[@intCast(try data.drawInteger(0, delays.len - 1, 0))];
158 const hops: u8 = @intCast(try data.drawInteger(0, 3, 0));
159 const blob = try drawBlob(data);
160 var frame: [wire.mtu]u8 = undefined;
161 const bytes = try announceFrame(
162 &private,
163 name_hash,
164 destination_hash,
165 blob,
166 hops,
167 &frame,
168 );
169 const effects = try owner.step(.{ .carrier_frame = .{
170 .interface = 0,
171 .now = now,
172 .bytes = bytes,
173 .entropy = @splat(0),
174 } });
175 try std.testing.expectEqual(model.offer(hops + 1, blob, now), accepted(effects));
176 }
177 }
178 };
179
180 test "property: node path learning agrees with the RNS/Transport.py:2137-2213 model" {
181 try hypothesis.checkNamed(
182 PathAddRules,
183 "reticulum-transport-path-add-rules",
184 settings(add_rules_seed),
185 );
186 }
187
188 const RewritesKeepHash = struct {
189 pub fn property(data: *hypothesis.ConjectureData, _: std.mem.Allocator) !void {
190 const payload = try data.drawBytes(0, wire.mtu - wire.header_two_bytes);
191 const destination_hash = (try data.drawBytes(16, 16))[0..16].*;
192 const hops: u8 = @intCast(try data.drawInteger(0, wire.pathfinder_hops - 1, 0));
193 var raw_storage: [wire.mtu]u8 = undefined;
194 const raw = try wire.encode(.{
195 .ifac = 0,
196 .header = .one,
197 .context_flag = @intCast(try data.drawInteger(0, 1, 0)),
198 .transport = .broadcast,
199 .destination_type = .single,
200 .packet_type = .data,
201 .hops = hops,
202 .transport_id = null,
203 .destination = destination_hash,
204 .context = .none,
205 .payload = payload,
206 }, &raw_storage);
207 const hash = try wire.hash.full(raw);
208 var inserted_storage: [wire.mtu]u8 = undefined;
209 const inserted = try node.transport.rewrite.insert(raw, @splat(0x11), &inserted_storage);
210 try std.testing.expectEqualSlices(u8, &hash, &try wire.hash.full(inserted));
211 var forwarded_storage: [wire.mtu]u8 = undefined;
212 const next_hop: [16]u8 = @splat(0x22);
213 const forwarded = node.transport.rewrite.forward(
214 inserted,
215 hops,
216 next_hop,
217 &forwarded_storage,
218 );
219 try std.testing.expectEqualSlices(u8, &hash, &try wire.hash.full(forwarded));
220 var stripped_storage: [wire.mtu]u8 = undefined;
221 const stripped = node.transport.rewrite.strip(forwarded, hops, &stripped_storage);
222 try std.testing.expectEqualSlices(u8, &hash, &try wire.hash.full(stripped));
223 try std.testing.expectEqualSlices(u8, payload, (try wire.decode(stripped)).payload);
224 }
225 };
226
227 test "property: transport insert, forward, and strip keep RNS/Packet.py:353-358 hashes" {
228 try hypothesis.checkNamed(
229 RewritesKeepHash,
230 "reticulum-transport-rewrites-keep-hash",
231 settings(rewrites_seed),
232 );
233 }
234
235 const RequestParseTotal = struct {
236 pub fn property(data: *hypothesis.ConjectureData, _: std.mem.Allocator) !void {
237 const payload = try data.drawBytes(0, wire.mtu);
238 const request = node.transport.requests.parse(payload) catch |err| {
239 switch (err) {
240 error.Short => try std.testing.expect(payload.len < 16),
241 error.Tagless => try std.testing.expectEqual(@as(usize, 16), payload.len),
242 }
243 return;
244 };
245 try std.testing.expect(payload.len > 16);
246 try std.testing.expectEqualSlices(u8, payload[0..16], &request.destination);
247 try std.testing.expectEqual(payload.len > 32, request.requestor != null);
248 const start: usize = if (payload.len > 32) 32 else 16;
249 const end = @min(payload.len, start + 16);
250 try std.testing.expectEqualSlices(u8, payload[start..end], request.tag);
251 }
252 };
253
254 test "property: path request parsing is total over RNS/Transport.py:1738-1753 payload lengths" {
255 try hypothesis.checkNamed(
256 RequestParseTotal,
257 "reticulum-transport-request-parse-total",
258 settings(request_parse_seed),
259 );
260 }
261
262 const fixture = node.fixture;
263
264 fn drawFault(data: *hypothesis.ConjectureData) !fixture.Fault {
265 const ordinal: u32 = @intCast(try data.drawInteger(0, 3, 0));
266 return switch (try data.drawInteger(0, 2, 0)) {
267 0 => .{ .drop = ordinal },
268 1 => .{ .delay = .{
269 .ordinal = ordinal,
270 .seconds = try data.drawInteger(1, 20, 1),
271 } },
272 else => .{ .swap = .{
273 .first = ordinal,
274 .second = @intCast(try data.drawInteger(0, 3, 0)),
275 } },
276 };
277 }
278
279 fn drawFaults(data: *hypothesis.ConjectureData, world: *fixture.World) !void {
280 inline for (.{ .a_to_b, .b_to_a, .b_to_c, .c_to_b }) |id| {
281 const count = try data.drawInteger(0, 2, 0);
282 var index: u64 = 0;
283 while (index < count) : (index += 1) {
284 const value = try drawFault(data);
285 if (value == .swap and value.swap.first == value.swap.second) continue;
286 world.linkAt(id).fault(value);
287 }
288 }
289 }
290
291 fn openWorld(world: *fixture.World, vector: reticulum.conformance.transport.ThreeNodeVector) !void {
292 try world.init(.{
293 .start = vector.start_clock,
294 .transport_hash = vector.transport_identity_hash[0..16].*,
295 });
296 const owner = world.at(.c);
297 owner.identities[0] = reticulum.identity.Private.fromBytes(
298 vector.destination_private_key[0..64].*,
299 );
300 try owner.destinations.register(.{
301 .hash = vector.destination_hash[0..16].*,
302 .name_hash = vector.destination_name_hash[0..10].*,
303 .kind = .single,
304 .proof_strategy = .all,
305 .identity_index = 0,
306 });
307 }
308
309 const SeededFaults = struct {
310 pub fn property(data: *hypothesis.ConjectureData, _: std.mem.Allocator) !void {
311 const vector = reticulum.conformance.transport.three_node_vectors[0];
312 var world: fixture.World = undefined;
313 try openWorld(&world, vector);
314 defer world.deinit();
315 try drawFaults(data, &world);
316 try world.step(.c, .{ .application_announce = .{
317 .destination = vector.destination_hash[0..16].*,
318 .app_data = vector.destination_app_data,
319 .random = vector.announce_random_hash[0..5].*,
320 .fresh_rotating_key = null,
321 .now = world.clock,
322 } });
323 try world.runTo(vector.send_clock);
324 const destination = vector.destination_hash[0..16].*;
325 if (world.at(.a).transport.paths.find(destination, world.clock) != null) {
326 try world.step(.a, .{ .application_send = .{
327 .destination = destination,
328 .now = world.clock,
329 .plaintext = vector.plaintext,
330 .ephemeral_private = vector.ephemeral_private_key[0..32].*,
331 .iv = vector.iv[0..16].*,
332 } });
333 }
334 try world.runTo(vector.send_clock + 30);
335 try std.testing.expect(world.records().len <= fixture.records_max);
336 inline for (.{ .a_to_b, .b_to_a, .b_to_c, .c_to_b }) |id| {
337 try std.testing.expect(world.frameCount(id) <= fixture.deliveries_max);
338 }
339 }
340 };
341
342 test "property: seeded loss, delay, and reorder settle the three-node fixture" {
343 try hypothesis.checkNamed(
344 SeededFaults,
345 "reticulum-fixture-faults-settle",
346 settings(fixture_faults_seed),
347 );
348 }