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 }