lib/reticulum/src/node/fixture/test.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const pretty = @import("pretty");
3 const reticulum = @import("../../root.zig");
4 const fixture = @import("root.zig");
5
6 const carrier = reticulum.carrier;
7 const ifac = reticulum.interface.ifac;
8 const node = reticulum.node;
9 const packet = reticulum.packet;
10 const wire = reticulum.wire;
11
12 const Vector = reticulum.conformance.transport.ThreeNodeVector;
13 const LinkVector = reticulum.conformance.link.ThreeNodeVector;
14 const World = fixture.World;
15
16 /// The seconds a reverse entry lives, so a relayed proof can still find its way back within them.
17 const proof_lifetime: fixture.Seconds = 480;
18
19 fn vector() Vector {
20 const vectors = reticulum.conformance.transport.three_node_vectors;
21 std.debug.assert(vectors.len == 1);
22 return vectors[0];
23 }
24
25 fn codes() !fixture.Codes {
26 return .{
27 .a_b = .{ .key = try ifac.derive("tiny", "a-b"), .size = .bytes_16 },
28 .b_c = .{ .key = try ifac.derive("tiny", "b-c"), .size = .bytes_8 },
29 };
30 }
31
32 fn options(keyed: bool, discovery: ?carrier.Index) !fixture.Options {
33 const proof = vector();
34 return .{
35 .start = proof.start_clock,
36 .transport_hash = proof.transport_identity_hash[0..16].*,
37 .codes = if (keyed) try codes() else .{},
38 .discover_paths = discovery,
39 };
40 }
41
42 fn open(world: *World, keyed: bool, discovery: ?carrier.Index) !void {
43 try world.init(try options(keyed, discovery));
44 const proof = vector();
45 const owner = world.at(.c);
46 owner.identities[0] = reticulum.identity.Private.fromBytes(
47 proof.destination_private_key[0..64].*,
48 );
49 try owner.destinations.register(.{
50 .hash = proof.destination_hash[0..16].*,
51 .name_hash = proof.destination_name_hash[0..10].*,
52 .kind = .single,
53 .proof_strategy = .all,
54 .identity_index = 0,
55 });
56 }
57
58 fn announce(world: *World, response: ?carrier.Index) !void {
59 const proof = vector();
60 try world.step(.c, .{ .application_announce = .{
61 .destination = proof.destination_hash[0..16].*,
62 .app_data = proof.destination_app_data,
63 .random = proof.announce_random_hash[0..5].*,
64 .fresh_rotating_key = null,
65 .now = world.clock,
66 .path_response = response,
67 } });
68 }
69
70 /// Puts the corpus data packet on the wire from node A toward node C's destination at whatever
71 /// second the world holds, after exclusive-oring the salt into the final byte of the initialization
72 /// vector. Salt 0 sends the corpus bytes as generated. Another salt gives a distinct packet, so one
73 /// world can carry more than one send.
74 fn sendData(world: *World, salt: u8) !void {
75 const proof = vector();
76 var iv = proof.iv[0..16].*;
77 iv[15] ^= salt;
78 try world.step(.a, .{ .application_send = .{
79 .destination = proof.destination_hash[0..16].*,
80 .now = world.clock,
81 .plaintext = proof.plaintext,
82 .ephemeral_private = proof.ephemeral_private_key[0..32].*,
83 .iv = iv,
84 } });
85 }
86
87 /// Alters the corpus tag, which lets a single world carry more than one path request that differs
88 /// from the rest.
89 fn tagged(salt: u8) [16]u8 {
90 var tag = vector().request_tag[0..16].*;
91 tag[15] ^= salt;
92 return tag;
93 }
94
95 fn requestPath(world: *World, tag: [16]u8) !void {
96 const proof = vector();
97 try world.step(.a, .{ .application_path_request = .{
98 .destination = proof.destination_hash[0..16].*,
99 .tag = tag,
100 .interface = null,
101 .now = world.clock,
102 } });
103 }
104
105 fn accessOf(keyed: bool, link: fixture.LinkId) !?node.Access {
106 if (!keyed) return null;
107 const pair = try codes();
108 return switch (link) {
109 .a_to_b, .b_to_a => pair.a_b,
110 .b_to_c, .c_to_b => pair.b_c,
111 };
112 }
113
114 fn expectFrame(
115 world: *const World,
116 keyed: bool,
117 link: fixture.LinkId,
118 index: usize,
119 expected: []const u8,
120 ) !void {
121 const delivery = world.frameOn(link, index) orelse return error.MissingLinkFrame;
122 const access = try accessOf(keyed, link);
123 var stripped: [carrier.frame_bytes_max]u8 = undefined;
124 const raw = if (access) |value|
125 try ifac.strip(&value.key, value.size, delivery.frame.slice(), &stripped)
126 else
127 delivery.frame.slice();
128 if (std.mem.eql(u8, expected, raw)) return;
129 return report(link, index, expected, raw);
130 }
131
132 fn report(link: fixture.LinkId, index: usize, expected: []const u8, actual: []const u8) !void {
133 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
134 defer arena.deinit();
135 var value = try pretty.diagnostic.Report.init(arena.allocator(), "World frame mismatch");
136 defer value.deinit();
137 try value.field("link", "{s}", .{@tagName(link)});
138 try value.field("index", "{d}", .{index});
139 try value.field("expected", "{any}", .{expected});
140 try value.field("actual", "{any}", .{actual});
141 pretty.diagnostic.writeStderr(&value, .{ .width = 100 });
142 return error.WorldFrameMismatch;
143 }
144
145 fn expectCounts(world: *const World, expected: [4]usize) !void {
146 inline for (.{ .a_to_b, .b_to_a, .b_to_c, .c_to_b }, 0..) |link, index| {
147 try std.testing.expectEqual(expected[index], world.frameCount(link));
148 }
149 }
150
151 fn frameAt(world: *const World, link: fixture.LinkId, index: usize) !fixture.Seconds {
152 const delivery = world.frameOn(link, index) orelse return error.MissingLinkFrame;
153 return delivery.at;
154 }
155
156 fn deadlineOf(world: *const World, owner: fixture.NodeId) !fixture.Seconds {
157 var found: ?fixture.Seconds = null;
158 for (world.records()) |entry| {
159 if (entry.owner != owner or entry.kind != .timer) continue;
160 if (entry.tag != .receipt) continue;
161 found = entry.deadline;
162 }
163 return found orelse error.MissingReceiptTimer;
164 }
165
166 fn codeCount(world: *const World, owner: fixture.NodeId, code: node.Code) usize {
167 var count: usize = 0;
168 for (world.records()) |entry| {
169 if (entry.owner != owner or entry.kind != .diagnostic) continue;
170 if (entry.code != code) continue;
171 count += 1;
172 }
173 return count;
174 }
175
176 /// Carries C's announce to A through B and checks every frame against the corpus, as
177 /// Reticulum@1.5.0 RNS/Transport.py:737-799 rebroadcasts it. It leaves A with a path two hops away
178 /// and leaves C reporting its own announce twice.
179 fn runAnnounceStep(world: *World, keyed: bool) !void {
180 const proof = vector();
181 try announce(world, null);
182 try world.runTo(proof.rebroadcast_clock);
183 try world.runTo(proof.retry_clock);
184 try world.runTo(proof.send_clock - 1);
185 try expectCounts(world, .{ 0, 2, 2, 1 });
186 try expectFrame(world, keyed, .c_to_b, 0, proof.announce_raw);
187 try expectFrame(world, keyed, .b_to_a, 0, proof.rebroadcast_raw);
188 try expectFrame(world, keyed, .b_to_c, 0, proof.rebroadcast_raw);
189 try expectFrame(world, keyed, .b_to_a, 1, proof.retry_raw);
190 try expectFrame(world, keyed, .b_to_c, 1, proof.retry_raw);
191 const learned = world.lastRecord(.a, .announce) orelse return error.MissingAnnounce;
192 try std.testing.expectEqual(@as(u8, 2), learned.hops);
193 try std.testing.expect(!learned.path_response);
194 try std.testing.expectEqual(@as(usize, 1), world.recordCount(.a, .announce));
195 try std.testing.expectEqual(@as(usize, 1), world.recordCount(.b, .announce));
196 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.c, .announce));
197 try std.testing.expectEqual(@as(usize, 2), codeCount(world, .c, .own_announce));
198 }
199
200 /// Sends the corpus data from A to C and checks that the packet and its proof cross B in both
201 /// directions, as Reticulum@1.5.0 RNS/Transport.py:1345-1356,2670-2677 crosses it. It checks each
202 /// of the four frames against the corpus bytes, the plaintext C delivered, and the receipt A
203 /// concluded. The `sent` counts say how many frames each link has already carried, so the step can
204 /// run in a world that has carried traffic before.
205 fn runRelayStep(world: *World, keyed: bool, sent: [4]usize) !void {
206 const proof = vector();
207 try sendData(world, 0);
208 try expectCounts(world, .{ sent[0] + 1, sent[1] + 1, sent[2] + 1, sent[3] + 1 });
209 try expectFrame(world, keyed, .a_to_b, sent[0], proof.inserted_raw);
210 try expectFrame(world, keyed, .b_to_c, sent[2], proof.stripped_raw);
211 try expectFrame(world, keyed, .c_to_b, sent[3], proof.proof_raw);
212 try expectFrame(world, keyed, .b_to_a, sent[1], proof.relayed_proof_raw);
213 const delivery = world.lastRecord(.c, .delivery) orelse return error.MissingDelivery;
214 try std.testing.expectEqualSlices(u8, proof.plaintext, delivery.plaintext());
215 try std.testing.expectEqual(@as(carrier.Index, 0), delivery.interface);
216 const receipt = world.lastRecord(.a, .receipt) orelse return error.MissingReceipt;
217 try std.testing.expectEqual(packet.receipt.Status.delivered, receipt.status.?);
218 }
219
220 test "Reticulum@1.5.0 RNS/Transport.py:737-799,1935-1946 relays data and its proof" {
221 var world: World = undefined;
222 try open(&world, false, null);
223 defer world.deinit();
224 try runAnnounceStep(&world, false);
225 try world.runTo(vector().send_clock);
226 try runRelayStep(&world, false, .{ 0, 2, 2, 1 });
227 try std.testing.expectEqual(vector().send_clock + 19, try deadlineOf(&world, .a));
228 try expectCounts(&world, .{ 1, 3, 3, 2 });
229 }
230
231 test "Reticulum@1.5.0 RNS/Transport.py:1244-1276 relays the same frames behind access codes" {
232 var world: World = undefined;
233 try open(&world, true, null);
234 defer world.deinit();
235 try runAnnounceStep(&world, true);
236 try world.runTo(vector().send_clock);
237 try runRelayStep(&world, true, .{ 0, 2, 2, 1 });
238 const pair = try codes();
239 const delivery = world.frameOn(.a_to_b, 0) orelse return error.MissingLinkFrame;
240 const masked = delivery.frame.slice();
241 try std.testing.expectEqual(vector().inserted_raw.len + pair.a_b.?.size.byte(), masked.len);
242 try std.testing.expect(ifac.hasFlag(masked[0]));
243 }
244
245 test "Reticulum@1.5.0 RNS/Transport.py:3409-3444 answers a lost path with one path response" {
246 const proof = vector();
247 var world: World = undefined;
248 try open(&world, false, null);
249 defer world.deinit();
250 world.linkAt(.b_to_a).fault(.{ .partition = .{
251 .from = proof.start_clock,
252 .until = proof.start_clock + 8,
253 } });
254 try announce(&world, null);
255 try world.runTo(proof.request_clock);
256 try expectCounts(&world, .{ 0, 0, 2, 1 });
257 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.a, .announce));
258 try requestPath(&world, tagged(0));
259 try expectFrame(&world, false, .a_to_b, 0, proof.request_raw);
260 try world.runTo(proof.response_clock);
261 try expectCounts(&world, .{ 1, 1, 2, 1 });
262 try expectFrame(&world, false, .b_to_a, 0, proof.response_raw);
263 const learned = world.lastRecord(.a, .announce) orelse return error.MissingAnnounce;
264 try std.testing.expectEqual(@as(u8, 2), learned.hops);
265 try std.testing.expect(learned.path_response);
266 try world.runTo(proof.response_clock + 1);
267 try runRelayStep(&world, false, .{ 1, 1, 2, 1 });
268 }
269
270 test "Reticulum@1.5.0 RNS/Transport.py:3478-3508 forwards one request and answers once" {
271 const proof = vector();
272 var world: World = undefined;
273 try open(&world, false, 0);
274 defer world.deinit();
275 try requestPath(&world, tagged(0));
276 try expectCounts(&world, .{ 1, 0, 1, 0 });
277 try expectFrame(&world, false, .a_to_b, 0, proof.request_raw);
278 const forwarded = world.frameOn(.b_to_c, 0) orelse return error.MissingLinkFrame;
279 const payload = forwarded.frame.slice()[19..];
280 try std.testing.expectEqual(@as(usize, 48), payload.len);
281 try std.testing.expectEqualSlices(u8, proof.destination_hash, payload[0..16]);
282 try std.testing.expectEqualSlices(u8, proof.transport_identity_hash, payload[16..32]);
283 try std.testing.expectEqualSlices(u8, proof.request_tag, payload[32..48]);
284 const asked = world.lastRecord(.c, .path_request) orelse return error.MissingPathRequest;
285 try std.testing.expectEqualSlices(u8, proof.destination_hash, &asked.key);
286 try announce(&world, 0);
287 try expectCounts(&world, .{ 1, 1, 1, 1 });
288 const relayed = world.lastRecord(.b, .announce) orelse return error.MissingAnnounce;
289 try std.testing.expectEqual(@as(u8, 1), relayed.hops);
290 const learned = world.lastRecord(.a, .announce) orelse return error.MissingAnnounce;
291 try std.testing.expectEqual(@as(u8, 2), learned.hops);
292 try std.testing.expect(learned.path_response);
293 try world.runTo(proof.retry_clock);
294 try expectCounts(&world, .{ 1, 1, 1, 1 });
295 }
296
297 test "Reticulum@1.5.0 RNS/Transport.py:1755-1797 batches a second tag and answers at once" {
298 const proof = vector();
299 var world: World = undefined;
300 try open(&world, false, null);
301 defer world.deinit();
302 try requestPath(&world, tagged(1));
303 try expectCounts(&world, .{ 1, 0, 0, 0 });
304 try std.testing.expectEqual(@as(usize, 0), codeCount(&world, .b, .path_request_batched));
305 try world.runTo(proof.start_clock + 5);
306 try requestPath(&world, tagged(2));
307 try expectCounts(&world, .{ 2, 0, 0, 0 });
308 try std.testing.expectEqual(@as(usize, 1), codeCount(&world, .b, .path_request_batched));
309 try world.runTo(proof.start_clock + 10);
310 try announce(&world, null);
311 try world.runTo(proof.start_clock + 17);
312 try expectCounts(&world, .{ 2, 3, 2, 1 });
313 try std.testing.expectEqual(proof.start_clock + 10, try frameAt(&world, .b_to_a, 0));
314 try std.testing.expectEqual(proof.start_clock + 11, try frameAt(&world, .b_to_a, 1));
315 try std.testing.expectEqual(proof.start_clock + 17, try frameAt(&world, .b_to_a, 2));
316 const answered = world.frameOn(.b_to_a, 0) orelse return error.MissingLinkFrame;
317 const rebroadcast = world.frameOn(.b_to_a, 1) orelse return error.MissingLinkFrame;
318 const retry = world.frameOn(.b_to_a, 2) orelse return error.MissingLinkFrame;
319 try std.testing.expect(!std.mem.eql(u8, answered.frame.slice(), rebroadcast.frame.slice()));
320 try std.testing.expectEqualSlices(u8, rebroadcast.frame.slice(), retry.frame.slice());
321 try std.testing.expectEqual(@as(usize, 1), world.recordCount(.a, .announce));
322 const learned = world.lastRecord(.a, .announce) orelse return error.MissingAnnounce;
323 try std.testing.expect(learned.path_response);
324 }
325
326 test "Reticulum@1.5.0 RNS/Transport.py:3520-3524 gates at plus 45 and reopens at plus 46" {
327 const proof = vector();
328 var world: World = undefined;
329 try open(&world, false, null);
330 defer world.deinit();
331 try requestPath(&world, tagged(1));
332 try world.runTo(proof.start_clock + 45);
333 try requestPath(&world, tagged(2));
334 try std.testing.expectEqual(@as(usize, 1), codeCount(&world, .b, .path_request_batched));
335 try world.runTo(proof.start_clock + 46);
336 try requestPath(&world, tagged(3));
337 try std.testing.expectEqual(@as(usize, 1), codeCount(&world, .b, .path_request_batched));
338 try expectCounts(&world, .{ 3, 0, 0, 0 });
339 try world.runTo(proof.start_clock + 62);
340 try announce(&world, null);
341 try world.runTo(proof.start_clock + 69);
342 try expectCounts(&world, .{ 3, 2, 2, 1 });
343 try std.testing.expectEqual(proof.start_clock + 63, try frameAt(&world, .b_to_a, 0));
344 try std.testing.expectEqual(proof.start_clock + 69, try frameAt(&world, .b_to_a, 1));
345 const rebroadcast = world.frameOn(.b_to_a, 0) orelse return error.MissingLinkFrame;
346 const retry = world.frameOn(.b_to_a, 1) orelse return error.MissingLinkFrame;
347 try std.testing.expectEqualSlices(u8, rebroadcast.frame.slice(), retry.frame.slice());
348 try std.testing.expectEqual(@as(usize, 1), world.recordCount(.a, .announce));
349 const learned = world.lastRecord(.a, .announce) orelse return error.MissingAnnounce;
350 try std.testing.expect(!learned.path_response);
351 }
352
353 test "Reticulum@1.5.0 RNS/Transport.py:2137-2213 keeps the newer announce past a late one" {
354 const proof = vector();
355 var world: World = undefined;
356 try open(&world, false, null);
357 defer world.deinit();
358 world.linkAt(.b_to_a).fault(.{ .delay = .{ .ordinal = 0, .seconds = 3 } });
359 try announce(&world, null);
360 try world.runTo(proof.start_clock + 2);
361 try announce(&world, null);
362 try world.runTo(proof.start_clock + 5);
363 try expectCounts(&world, .{ 0, 2, 2, 2 });
364 try std.testing.expectEqual(proof.start_clock + 3, try frameAt(&world, .b_to_a, 0));
365 try std.testing.expectEqual(proof.start_clock + 4, try frameAt(&world, .b_to_a, 1));
366 try std.testing.expectEqual(@as(u32, 1), world.frameOn(.b_to_a, 0).?.ordinal);
367 try std.testing.expectEqual(@as(u32, 0), world.frameOn(.b_to_a, 1).?.ordinal);
368 try std.testing.expectEqual(@as(usize, 1), world.recordCount(.a, .announce));
369 const learned = world.lastRecord(.a, .announce) orelse return error.MissingAnnounce;
370 try std.testing.expectEqual(proof.start_clock + 3, learned.at);
371 }
372
373 test "Reticulum@1.5.0 RNS/Packet.py:540-548 fails the receipt when B cannot reach C" {
374 const proof = vector();
375 var world: World = undefined;
376 try open(&world, false, null);
377 defer world.deinit();
378 try runAnnounceStep(&world, false);
379 world.linkAt(.b_to_c).fault(.{ .partition = .{
380 .from = proof.send_clock,
381 .until = proof.send_clock + 1,
382 } });
383 try world.runTo(proof.send_clock);
384 try sendData(&world, 0);
385 try expectCounts(&world, .{ 1, 2, 2, 1 });
386 try world.runTo(proof.send_clock + 19);
387 const receipt = world.lastRecord(.a, .receipt) orelse return error.MissingReceipt;
388 try std.testing.expectEqual(packet.receipt.Status.failed, receipt.status.?);
389 try std.testing.expectEqual(proof.send_clock + 19, receipt.at);
390 }
391
392 fn runDelayedProof(world: *World, delay: fixture.Seconds) !void {
393 const proof = vector();
394 try open(world, false, null);
395 try runAnnounceStep(world, false);
396 world.linkAt(.c_to_b).fault(.{ .delay = .{ .ordinal = 1, .seconds = delay } });
397 try world.runTo(proof.send_clock);
398 try sendData(world, 0);
399 try world.runTo(proof.send_clock + delay);
400 }
401
402 test "Reticulum@1.5.0 RNS/Transport.py:834-841 relays a proof at plus 480 and drops plus 481" {
403 var relayed: World = undefined;
404 try runDelayedProof(&relayed, proof_lifetime);
405 defer relayed.deinit();
406 try expectCounts(&relayed, .{ 1, 3, 3, 2 });
407 try expectFrame(&relayed, false, .b_to_a, 2, vector().relayed_proof_raw);
408 try std.testing.expectEqual(@as(usize, 0), codeCount(&relayed, .b, .proof_rejected));
409 var dropped: World = undefined;
410 try runDelayedProof(&dropped, proof_lifetime + 1);
411 defer dropped.deinit();
412 try expectCounts(&dropped, .{ 1, 2, 3, 2 });
413 try std.testing.expectEqual(@as(usize, 1), codeCount(&dropped, .b, .proof_rejected));
414 }
415
416 fn expectDelivery(
417 world: *const World,
418 link: fixture.LinkId,
419 index: usize,
420 ordinal: u32,
421 at: fixture.Seconds,
422 ) !void {
423 const delivery = world.frameOn(link, index) orelse return error.MissingLinkFrame;
424 try std.testing.expectEqual(ordinal, delivery.ordinal);
425 try std.testing.expectEqual(at, delivery.at);
426 }
427
428 test "fixture frames due in one second leave in arrival order behind an earlier frame" {
429 const proof = vector();
430 var world: World = undefined;
431 try open(&world, false, null);
432 defer world.deinit();
433 try runAnnounceStep(&world, false);
434 try world.runTo(proof.send_clock);
435 const link = world.linkAt(.a_to_b);
436 for ([_]fixture.Seconds{ 3, 3, 1, 3 }, 0..) |seconds, ordinal| {
437 link.fault(.{ .delay = .{ .ordinal = @intCast(ordinal), .seconds = seconds } });
438 }
439 for (0..4) |salt| try sendData(&world, @intCast(salt));
440 try world.runTo(proof.send_clock + 3);
441 try expectDelivery(&world, .a_to_b, 0, 2, proof.send_clock + 1);
442 try expectDelivery(&world, .a_to_b, 1, 0, proof.send_clock + 3);
443 try expectDelivery(&world, .a_to_b, 2, 1, proof.send_clock + 3);
444 try expectDelivery(&world, .a_to_b, 3, 3, proof.send_clock + 3);
445 }
446
447 fn runSwap(world: *World, delays: [2]fixture.Seconds) !void {
448 const proof = vector();
449 try open(world, false, null);
450 try runAnnounceStep(world, false);
451 try world.runTo(proof.send_clock);
452 const link = world.linkAt(.a_to_b);
453 link.fault(.{ .delay = .{ .ordinal = 0, .seconds = delays[0] } });
454 link.fault(.{ .delay = .{ .ordinal = 1, .seconds = delays[1] } });
455 link.fault(.{ .swap = .{ .first = 0, .second = 1 } });
456 try sendData(world, 0);
457 try sendData(world, 1);
458 try world.runTo(proof.send_clock + @max(delays[0], delays[1]));
459 }
460
461 test "fixture swap trades places within one second and leaves each frame its own delay" {
462 const start = vector().send_clock;
463 var apart: World = undefined;
464 try runSwap(&apart, .{ 1, 5 });
465 defer apart.deinit();
466 try expectDelivery(&apart, .a_to_b, 0, 0, start + 1);
467 try expectDelivery(&apart, .a_to_b, 1, 1, start + 5);
468 var together: World = undefined;
469 try runSwap(&together, .{ 2, 2 });
470 defer together.deinit();
471 try expectDelivery(&together, .a_to_b, 0, 1, start + 2);
472 try expectDelivery(&together, .a_to_b, 1, 0, start + 2);
473 }
474
475 test "fixture holds only the timers its nodes hold across ten proved sends" {
476 const proof = vector();
477 var world: World = undefined;
478 try open(&world, false, null);
479 defer world.deinit();
480 try runAnnounceStep(&world, false);
481 try world.runTo(proof.send_clock);
482 for (0..10) |salt| {
483 try sendData(&world, @intCast(salt));
484 inline for (.{ .a, .b, .c }) |owner| {
485 try std.testing.expect(world.timerCount(owner) <= world.at(owner).timers.count());
486 }
487 }
488 try std.testing.expectEqual(@as(usize, 10), world.recordCount(.a, .receipt));
489 try expectCounts(&world, .{ 10, 12, 12, 11 });
490 }
491
492 /// The recorded three-node link session, which A opens to C across the transport node B.
493 fn session() LinkVector {
494 const vectors = reticulum.conformance.link.three_node_vectors;
495 std.debug.assert(vectors.len == 1);
496 return vectors[0];
497 }
498
499 /// The relayed link proof is the third frame B offers A, behind the announce rebroadcast and its
500 /// retry. The recording put one second between A's request and that proof, so the world holds the
501 /// proof back one second and A measures the round trip the recording measured.
502 const relayed_proof_ordinal: u32 = 2;
503 const relayed_proof_delay: fixture.Seconds = 1;
504
505 /// A's link request waits six seconds plus six per hop over a path of two hops.
506 /// Reticulum@1.5.0 RNS/Link.py:281-283.
507 const initiator_establishment: fixture.Seconds = 18;
508 /// C receives a request with two hops, so its establishment timeout is 6 * 2 + 360 seconds.
509 /// Reticulum@1.5.0 RNS/Link.py:204.
510 const responder_establishment: fixture.Seconds = 372;
511 /// B holds an unproved relay for six seconds per remaining hop and drops it one second later.
512 /// Reticulum@1.5.0 RNS/Transport.py:850,1971.
513 const relay_proof_drop: fixture.Seconds = 7;
514
515 fn openSession(world: *World, keyed: bool) !void {
516 const link = session();
517 try world.init(.{
518 .start = link.start_clock,
519 .transport_hash = link.transport_identity_hash[0..16].*,
520 .codes = if (keyed) try codes() else .{},
521 .entropy = .{
522 .a = link.rtt_entropy[0..32].*,
523 .c = link.responder_entropy[0..32].*,
524 },
525 });
526 const owner = world.at(.c);
527 owner.identities[0] = reticulum.identity.Private.fromBytes(
528 link.destination_private_key[0..64].*,
529 );
530 try owner.destinations.register(.{
531 .hash = link.destination_hash[0..16].*,
532 .name_hash = link.destination_name_hash[0..10].*,
533 .kind = .single,
534 .proof_strategy = .all,
535 .identity_index = 0,
536 });
537 }
538
539 fn announceSession(world: *World) !void {
540 const link = session();
541 try world.step(.c, .{ .application_announce = .{
542 .destination = link.destination_hash[0..16].*,
543 .app_data = link.destination_app_data,
544 .random = link.announce_random_hash[0..5].*,
545 .fresh_rotating_key = null,
546 .now = world.clock,
547 .path_response = null,
548 } });
549 }
550
551 fn linkSend(world: *World, id: fixture.NodeId, plaintext: []const u8, iv: [16]u8) !void {
552 try world.step(id, .{ .application_link_send = .{
553 .link_id = session().link_id[0..16].*,
554 .plaintext = plaintext,
555 .iv = iv,
556 .now = world.clock,
557 } });
558 }
559
560 fn linkClose(world: *World, id: fixture.NodeId, iv: [16]u8) !void {
561 try world.step(id, .{ .application_link_close = .{
562 .link_id = session().link_id[0..16].*,
563 .iv = iv,
564 .now = world.clock,
565 } });
566 }
567
568 /// Reports whether B's packet filter holds the hash of this frame. The hops byte sits outside the
569 /// hashed part, so a relayed frame and the frame it came from share one hash
570 /// (Reticulum@1.5.0 RNS/Packet.py:353-358).
571 fn relayHolds(world: *World, raw: []const u8) !bool {
572 const hash = try wire.hash.full(raw);
573 return world.at(.b).duplicate_hashes.contains(hash);
574 }
575
576 fn closeReasonOf(world: *const World, id: fixture.NodeId) !node.LinkCloseReason {
577 const closed = world.lastRecord(id, .link_closed) orelse return error.MissingLinkClose;
578 return closed.reason orelse error.MissingCloseReason;
579 }
580
581 fn deliveredCount(world: *const World, id: fixture.NodeId) usize {
582 var count: usize = 0;
583 for (world.records()) |entry| {
584 if (entry.owner != id or entry.kind != .receipt) continue;
585 if (entry.status != packet.receipt.Status.delivered) continue;
586 count += 1;
587 }
588 return count;
589 }
590
591 /// Reticulum@1.5.0 RNS/Transport.py:737-799 carries C's announce to A through B, so A recalls the
592 /// identity that a link request needs.
593 fn runSessionAnnounce(world: *World, keyed: bool) !void {
594 const link = session();
595 try announceSession(world);
596 try world.runTo(link.retry_clock);
597 try expectCounts(world, .{ 0, 2, 2, 1 });
598 try expectFrame(world, keyed, .c_to_b, 0, link.announce_raw);
599 try expectFrame(world, keyed, .b_to_a, 0, link.rebroadcast_raw);
600 try expectFrame(world, keyed, .b_to_a, 1, link.retry_raw);
601 try expectFrame(world, keyed, .b_to_c, 0, link.rebroadcast_raw);
602 try expectFrame(world, keyed, .b_to_c, 1, link.retry_raw);
603 const learned = world.lastRecord(.a, .announce) orelse return error.MissingAnnounce;
604 try std.testing.expectEqual(@as(u8, 2), learned.hops);
605 }
606
607 fn openLink(world: *World) !void {
608 const link = session();
609 try world.step(.a, .{ .application_link_open = .{
610 .destination = link.destination_hash[0..16].*,
611 .encryption_private = link.initiator_encryption_private_key[0..32].*,
612 .signing_private = link.initiator_signing_private_key[0..32].*,
613 .now = world.clock,
614 .proof_strategy = .all,
615 } });
616 }
617
618 /// Section 6 step 1. A opens a link to C, B strips and relays the request, C proves it, B relays
619 /// the proof, and A answers with its round trip time.
620 fn runSessionEstablish(world: *World, keyed: bool) !void {
621 const link = session();
622 try runSessionAnnounce(world, keyed);
623 world.linkAt(.b_to_a).fault(.{ .delay = .{
624 .ordinal = relayed_proof_ordinal,
625 .seconds = relayed_proof_delay,
626 } });
627 try world.runTo(link.request_clock);
628 try openLink(world);
629 try expectCounts(world, .{ 1, 2, 3, 2 });
630 try expectFrame(world, keyed, .a_to_b, 0, link.request_raw);
631 try expectFrame(world, keyed, .b_to_c, 2, link.stripped_raw);
632 try expectFrame(world, keyed, .c_to_b, 1, link.proof_raw);
633 try world.runTo(link.proof_clock);
634 try expectCounts(world, .{ 2, 3, 4, 2 });
635 try expectFrame(world, keyed, .b_to_a, 2, link.relayed_proof_raw);
636 try expectFrame(world, keyed, .a_to_b, 1, link.rtt_raw);
637 try expectFrame(world, keyed, .b_to_c, 3, link.relayed_rtt_raw);
638 const opened = world.lastRecord(.a, .link_established) orelse return error.MissingLink;
639 try std.testing.expectEqual(node.LinkRole.initiator, opened.role.?);
640 try std.testing.expectEqualSlices(u8, link.link_id, &opened.key);
641 const answered = world.lastRecord(.c, .link_established) orelse return error.MissingLink;
642 try std.testing.expectEqual(node.LinkRole.responder, answered.role.?);
643 try std.testing.expectEqualSlices(u8, link.link_id, &answered.key);
644 try std.testing.expectEqual(link.proof_clock, answered.at);
645 }
646
647 /// Section 6 step 2. A sends data and C answers, and each packet's proof reaches its sender.
648 fn runSessionTraffic(world: *World, keyed: bool) !void {
649 const link = session();
650 try world.runTo(link.data_clock);
651 try linkSend(world, .a, link.data_plaintext, link.data_iv[0..16].*);
652 try expectCounts(world, .{ 3, 4, 5, 3 });
653 try expectFrame(world, keyed, .a_to_b, 2, link.data_raw);
654 try expectFrame(world, keyed, .b_to_c, 4, link.relayed_data_raw);
655 try expectFrame(world, keyed, .c_to_b, 2, link.data_proof_raw);
656 try expectFrame(world, keyed, .b_to_a, 3, link.relayed_data_proof_raw);
657 const heard = world.lastRecord(.c, .link_delivery) orelse return error.MissingLinkDelivery;
658 try std.testing.expectEqualSlices(u8, link.data_plaintext, heard.plaintext());
659 try world.runTo(link.answer_clock);
660 try linkSend(world, .c, link.answer_plaintext, link.answer_iv[0..16].*);
661 try expectCounts(world, .{ 4, 5, 6, 4 });
662 try expectFrame(world, keyed, .c_to_b, 3, link.answer_raw);
663 try expectFrame(world, keyed, .b_to_a, 4, link.relayed_answer_raw);
664 try expectFrame(world, keyed, .a_to_b, 3, link.answer_proof_raw);
665 try expectFrame(world, keyed, .b_to_c, 5, link.relayed_answer_proof_raw);
666 const back = world.lastRecord(.a, .link_delivery) orelse return error.MissingLinkDelivery;
667 try std.testing.expectEqualSlices(u8, link.answer_plaintext, back.plaintext());
668 try std.testing.expectEqual(@as(usize, 1), deliveredCount(world, .a));
669 try std.testing.expectEqual(@as(usize, 1), deliveredCount(world, .c));
670 }
671
672 /// Section 6 step 3. A sends a keepalive request at its deadline and C answers it.
673 fn runSessionKeepalive(world: *World, keyed: bool) !void {
674 const link = session();
675 try world.runTo(link.keepalive_clock - 1);
676 try expectCounts(world, .{ 4, 5, 6, 4 });
677 try world.runTo(link.keepalive_clock);
678 try expectCounts(world, .{ 5, 6, 7, 5 });
679 try expectFrame(world, keyed, .a_to_b, 4, link.keepalive_raw);
680 try expectFrame(world, keyed, .b_to_c, 6, link.relayed_keepalive_raw);
681 try expectFrame(world, keyed, .c_to_b, 4, link.keepalive_answer_raw);
682 try expectFrame(world, keyed, .b_to_a, 5, link.relayed_keepalive_answer_raw);
683 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.a, .link_closed));
684 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.c, .link_closed));
685 }
686
687 /// Section 6 step 4. A tears the link down and C closes with reason initiator closed.
688 fn runSessionClose(world: *World, keyed: bool) !void {
689 const link = session();
690 try world.runTo(link.close_clock);
691 try linkClose(world, .a, link.close_iv[0..16].*);
692 try expectCounts(world, .{ 6, 6, 8, 5 });
693 try expectFrame(world, keyed, .a_to_b, 5, link.close_raw);
694 try expectFrame(world, keyed, .b_to_c, 7, link.relayed_close_raw);
695 try std.testing.expectEqual(
696 node.LinkCloseReason.initiator_closed,
697 try closeReasonOf(world, .a),
698 );
699 try std.testing.expectEqual(
700 node.LinkCloseReason.initiator_closed,
701 try closeReasonOf(world, .c),
702 );
703 }
704
705 test "Reticulum@1.5.0 RNS/Link.py:304-322 opens a link to C across the transport node B" {
706 var world: World = undefined;
707 try openSession(&world, false);
708 defer world.deinit();
709 try runSessionEstablish(&world, false);
710 }
711
712 test "Reticulum@1.5.0 RNS/Link.py:378-389,948-962 carries link data and its proofs both ways" {
713 var world: World = undefined;
714 try openSession(&world, false);
715 defer world.deinit();
716 try runSessionEstablish(&world, false);
717 try runSessionTraffic(&world, false);
718 }
719
720 test "Reticulum@1.5.0 RNS/Transport.py:1863,2065 stores link hashes but never an LRPROOF one" {
721 const link = session();
722 var world: World = undefined;
723 try openSession(&world, false);
724 defer world.deinit();
725 try runSessionEstablish(&world, false);
726 try runSessionTraffic(&world, false);
727 try std.testing.expect(!try relayHolds(&world, link.proof_raw));
728 try std.testing.expect(try relayHolds(&world, link.request_raw));
729 try std.testing.expect(try relayHolds(&world, link.data_raw));
730 try std.testing.expect(try relayHolds(&world, link.answer_raw));
731 try std.testing.expect(try relayHolds(&world, link.data_proof_raw));
732 try std.testing.expect(try relayHolds(&world, link.answer_proof_raw));
733 }
734
735 test "Reticulum@1.5.0 RNS/Link.py:744-760,1130-1135 keeps a relayed link alive with keepalives" {
736 var world: World = undefined;
737 try openSession(&world, false);
738 defer world.deinit();
739 try runSessionEstablish(&world, false);
740 try runSessionTraffic(&world, false);
741 try runSessionKeepalive(&world, false);
742 }
743
744 test "Reticulum@1.5.0 RNS/Link.py:657-683 tears a relayed link down from its initiator" {
745 var world: World = undefined;
746 try openSession(&world, false);
747 defer world.deinit();
748 try runSessionEstablish(&world, false);
749 try runSessionTraffic(&world, false);
750 try runSessionKeepalive(&world, false);
751 try runSessionClose(&world, false);
752 }
753
754 test "Reticulum@1.5.0 RNS/Link.py:657-683 tears a relayed link down from its responder" {
755 const link = session();
756 var world: World = undefined;
757 try openSession(&world, false);
758 defer world.deinit();
759 try runSessionEstablish(&world, false);
760 try runSessionTraffic(&world, false);
761 try world.runTo(link.answer_clock + 1);
762 try linkClose(&world, .c, link.close_iv[0..16].*);
763 try expectCounts(&world, .{ 4, 6, 6, 5 });
764 try std.testing.expectEqual(
765 node.LinkCloseReason.destination_closed,
766 try closeReasonOf(&world, .c),
767 );
768 try std.testing.expectEqual(
769 node.LinkCloseReason.destination_closed,
770 try closeReasonOf(&world, .a),
771 );
772 }
773
774 test "Reticulum@1.5.0 RNS/Transport.py:1244-1276 relays every link frame behind access codes" {
775 const link = session();
776 var world: World = undefined;
777 try openSession(&world, true);
778 defer world.deinit();
779 try runSessionEstablish(&world, true);
780 try runSessionTraffic(&world, true);
781 try runSessionKeepalive(&world, true);
782 try runSessionClose(&world, true);
783 const pair = try codes();
784 const request = world.frameOn(.a_to_b, 0) orelse return error.MissingLinkFrame;
785 const masked = request.frame.slice();
786 try std.testing.expectEqual(link.request_raw.len + pair.a_b.?.size.byte(), masked.len);
787 try std.testing.expect(ifac.hasFlag(masked[0]));
788 }
789
790 test "Reticulum@1.5.0 RNS/Transport.py:858-865 loses the link proof and unwinds all three nodes" {
791 const link = session();
792 var world: World = undefined;
793 try openSession(&world, false);
794 defer world.deinit();
795 try runSessionAnnounce(&world, false);
796 world.linkAt(.c_to_b).fault(.{ .drop = 1 });
797 try world.runTo(link.request_clock);
798 try openLink(&world);
799 try expectCounts(&world, .{ 1, 2, 3, 1 });
800 try std.testing.expectEqual(@as(usize, 1), world.at(.b).transport.link_entries.count());
801 try std.testing.expectEqual(@as(usize, 1), world.at(.c).transport.links.count());
802 try std.testing.expectEqual(@as(usize, 1), world.timerCount(.c));
803 const responder = world.at(.c).transport.links.find(link.link_id[0..16].*) orelse
804 return error.MissingLink;
805 const responder_deadline = responder.request_time + responder.establishment_timeout;
806 try std.testing.expectEqual(link.request_clock + responder_establishment, responder_deadline);
807 try world.runTo(link.request_clock + relay_proof_drop - 1);
808 try std.testing.expectEqual(@as(usize, 1), world.at(.b).transport.link_entries.count());
809 try world.runTo(link.request_clock + relay_proof_drop);
810 try std.testing.expectEqual(@as(usize, 0), world.at(.b).transport.link_entries.count());
811 const destination = link.destination_hash[0..16].*;
812 const path = world.at(.b).transport.paths.find(destination, world.clock) orelse
813 return error.MissingPath;
814 try std.testing.expectEqual(node.transport.path.State.unresponsive, path.state);
815 try world.runTo(link.request_clock + initiator_establishment - 1);
816 try std.testing.expectEqual(@as(usize, 1), world.at(.a).transport.links.count());
817 try world.runTo(link.request_clock + initiator_establishment);
818 try std.testing.expectEqual(node.LinkCloseReason.timeout, try closeReasonOf(&world, .a));
819 try std.testing.expect(world.at(.a).transport.paths.find(destination, world.clock) == null);
820 const asked = world.frameOn(.a_to_b, 1) orelse return error.MissingLinkFrame;
821 try std.testing.expectEqual(link.request_clock + initiator_establishment, asked.at);
822 const payload = asked.frame.slice()[19..];
823 try std.testing.expectEqualSlices(u8, link.destination_hash, payload[0..16]);
824 try std.testing.expectEqual(@as(usize, 1), world.timerCount(.c));
825 try world.runTo(responder_deadline - 1);
826 try std.testing.expectEqual(@as(usize, 1), world.at(.c).transport.links.count());
827 try world.runTo(responder_deadline);
828 try std.testing.expectEqual(@as(usize, 0), world.at(.c).transport.links.count());
829 try std.testing.expectEqual(node.LinkCloseReason.timeout, try closeReasonOf(&world, .c));
830 }
831
832 test "Reticulum@1.5.0 RNS/Link.py:744-766 times a partitioned link out and still closes C" {
833 const link = session();
834 var world: World = undefined;
835 try openSession(&world, false);
836 defer world.deinit();
837 try runSessionEstablish(&world, false);
838 const quiet_since = link.proof_clock;
839 const stale_at = quiet_since + 412;
840 const close_at = stale_at + 5;
841 world.linkAt(.b_to_a).fault(.{ .partition = .{ .from = quiet_since + 1, .until = close_at } });
842 try world.runTo(stale_at - 1);
843 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.a, .link_closed));
844 try world.runTo(close_at - 1);
845 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.a, .link_closed));
846 try world.runTo(close_at);
847 try std.testing.expectEqual(node.LinkCloseReason.timeout, try closeReasonOf(&world, .a));
848 try std.testing.expectEqual(close_at, world.lastRecord(.a, .link_closed).?.at);
849 try std.testing.expectEqual(
850 node.LinkCloseReason.initiator_closed,
851 try closeReasonOf(&world, .c),
852 );
853 try std.testing.expectEqual(close_at, world.lastRecord(.c, .link_closed).?.at);
854 }
855
856 test "issue:tiny-6nihz8lx delivers two swapped link data frames and proves each one" {
857 const link = session();
858 var world: World = undefined;
859 try openSession(&world, false);
860 defer world.deinit();
861 try runSessionEstablish(&world, false);
862 const wire_a_b = world.linkAt(.a_to_b);
863 wire_a_b.fault(.{ .delay = .{ .ordinal = 2, .seconds = 2 } });
864 wire_a_b.fault(.{ .delay = .{ .ordinal = 3, .seconds = 2 } });
865 wire_a_b.fault(.{ .swap = .{ .first = 2, .second = 3 } });
866 try world.runTo(link.data_clock);
867 var second_iv = link.data_iv[0..16].*;
868 second_iv[15] ^= 1;
869 try linkSend(&world, .a, link.data_plaintext, link.data_iv[0..16].*);
870 try linkSend(&world, .a, link.data_plaintext, second_iv);
871 try expectCounts(&world, .{ 2, 3, 4, 2 });
872 try world.runTo(link.data_clock + 2);
873 try expectCounts(&world, .{ 4, 5, 6, 4 });
874 try expectDelivery(&world, .a_to_b, 2, 3, link.data_clock + 2);
875 try expectDelivery(&world, .a_to_b, 3, 2, link.data_clock + 2);
876 try std.testing.expectEqual(@as(usize, 2), world.recordCount(.c, .link_delivery));
877 try std.testing.expectEqual(@as(usize, 2), deliveredCount(&world, .a));
878 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.a, .link_closed));
879 try std.testing.expectEqual(@as(usize, 0), world.recordCount(.c, .link_closed));
880 const held = world.at(.a).transport.links.find(link.link_id[0..16].*) orelse
881 return error.MissingLink;
882 try std.testing.expectEqual(node.transport.links.Status.active, held.status);
883 }