lib/reticulum/src/node/transport/path.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3 const carrier = @import("../../carrier/root.zig");
4 const destination = @import("../../destination/root.zig");
5 const packet = @import("../../packet/root.zig");
6 const wire = @import("../../wire/root.zig");
7
8 pub const Seconds = u64;
9 pub const Blob = [10]u8;
10
11 /// Sixty-four, the number of recent random byte strings Reticulum@1.5.0
12 /// RNS/Transport.py:159 keeps per path.
13 pub const random_blobs_max: u8 = 64;
14 /// One week, the lifetime Reticulum@1.5.0 RNS/Transport.py:123,152 gives a
15 /// path.
16 pub const lifetime: Seconds = 60 * 60 * 24 * 7;
17
18 /// Whether the last thing sent over a path got through. Reticulum@1.5.0
19 /// RNS/Transport.py:145-147 draws the same three-way distinction.
20 pub const State = enum(u2) {
21 unknown,
22 unresponsive,
23 responsive,
24 };
25
26 /// One path the node worked out, holding the announces it has already taken and
27 /// the payload of the latest one.
28 pub const Entry = struct {
29 destination: [16]u8,
30 next_hop: [16]u8,
31 announce_hash: packet.Hash,
32 timestamp: Seconds,
33 expires: Seconds,
34 hops: u8,
35 carrier: carrier.Index,
36 state: State,
37 context_flag: u1,
38 blob_count: u8,
39 blob_next: u8,
40 payload_len: u16,
41 blobs: [random_blobs_max]Blob,
42 payload_bytes: [destination.announce.payload_bytes_max]u8,
43
44 /// Hands back the announce payload the path kept, and null when that
45 /// payload came in above 465 bytes under a HEADER_1 header and was
46 /// therefore never copied.
47 pub fn announcePayload(self: *const Entry) ?[]const u8 {
48 std.debug.assert(self.payload_len <= self.payload_bytes.len);
49 if (self.payload_len == 0) return null;
50 return self.payload_bytes[0..self.payload_len];
51 }
52
53 pub fn holdsBlob(self: *const Entry, blob: Blob) bool {
54 std.debug.assert(self.blob_count <= random_blobs_max);
55 for (self.blobs[0..self.blob_count]) |held| {
56 if (std.mem.eql(u8, &held, &blob)) return true;
57 }
58 return false;
59 }
60
61 /// Takes the newest emission time among the announces the path stores,
62 /// following Reticulum@1.5.0 RNS/Transport.py:3654-3660.
63 pub fn timebase(self: *const Entry) Seconds {
64 std.debug.assert(self.blob_count <= random_blobs_max);
65 var newest: Seconds = 0;
66 for (self.blobs[0..self.blob_count]) |held| newest = @max(newest, emission(held));
67 return newest;
68 }
69 };
70
71 /// Reads bytes 5 through 10 of the announce's random bytes as an emission time,
72 /// following Reticulum@1.5.0 RNS/Transport.py:3650-3651.
73 pub fn emission(blob: Blob) Seconds {
74 return std.mem.readInt(u40, blob[5..10], .big);
75 }
76
77 /// One announce whose signature has checked out, put forward for the path
78 /// store.
79 pub const Candidate = struct {
80 destination: [16]u8,
81 next_hop: [16]u8,
82 announce_hash: packet.Hash,
83 hops: u8,
84 carrier: carrier.Index,
85 context_flag: u1,
86 blob: Blob,
87 payload: []const u8,
88 };
89
90 /// Decides whether an announce replaces a path, following Reticulum@1.5.0
91 /// RNS/Transport.py:2137-2213. A destination with no path admits any announce.
92 /// An announce at the path's hop count or nearer is taken when the path has not
93 /// seen it before and it was emitted after the path's timebase. A farther
94 /// announce is taken on a path past its week when the path has not seen it
95 /// before. A farther announce emitted after the timebase is taken when the path
96 /// has not seen it before. A farther announce emitted before the timebase is
97 /// refused. On a path marked unresponsive, an announce it has already taken
98 /// still wins the path back, provided that announce is stamped at the same
99 /// instant as the newest one the path holds and comes from further off.
100 /// Reticulum@1.5.0 RNS/Transport.py:2208-2212 is the one place a repeat gets
101 /// through.
102 pub fn admits(known: ?*const Entry, candidate: Candidate, now: Seconds) bool {
103 std.debug.assert(candidate.hops <= wire.pathfinder_hops);
104 const entry = known orelse return true;
105 std.debug.assert(std.mem.eql(u8, &entry.destination, &candidate.destination));
106 const unseen = !entry.holdsBlob(candidate.blob);
107 const emitted = emission(candidate.blob);
108 const base = entry.timebase();
109 if (candidate.hops <= entry.hops) return unseen and emitted > base;
110 if (now >= entry.expires) return unseen;
111 if (emitted > base) return unseen;
112 if (emitted < base) return false;
113 return entry.state == .unresponsive;
114 }
115
116 /// Reports whether a path entry still counts. A path stops counting one week
117 /// past the second it was written, the age Reticulum@1.5.0
118 /// RNS/Transport.py:940-943 sets. A zero expiry marks a path that `expire`
119 /// dropped.
120 fn live(entry: *const Entry, now: Seconds) bool {
121 if (entry.expires == 0) return false;
122 return now -| entry.timestamp <= lifetime;
123 }
124
125 const TableLimits = struct {
126 paths_max: usize,
127 };
128
129 const TableCapacity = struct {
130 paths_max: usize,
131 storage_bytes: usize,
132
133 pub const DeriveError = error{ InvalidLimit, CapacityOverflow };
134
135 pub fn derive(limits: TableLimits) DeriveError!TableCapacity {
136 if (limits.paths_max == 0) return error.InvalidLimit;
137 const storage_bytes = alloc_phase.capacity.mul(
138 usize,
139 limits.paths_max,
140 @sizeOf(Entry),
141 ) catch return error.CapacityOverflow;
142 return .{ .paths_max = limits.paths_max, .storage_bytes = storage_bytes };
143 }
144 };
145
146 /// Paths learned from announces, matching Reticulum@1.5.0
147 /// RNS/Transport.py:2374-2375.
148 pub const Table = struct {
149 phase: alloc_phase.capacity.Phase,
150 capacity: Capacity,
151 storage: Storage,
152 entries: []Entry,
153 len: usize = 0,
154
155 pub const storage_alignment: usize = 8;
156 pub const Storage = []align(storage_alignment) u8;
157 pub const Limits: type = TableLimits;
158 pub const Capacity: type = TableCapacity;
159 pub const InitError = Capacity.DeriveError || error{StorageLengthMismatch};
160 pub const work_limits: alloc_phase.capacity.WorkLimits = .{
161 .transition_steps_max = 65_536,
162 .cleanup_steps_per_call_max = 0,
163 .cleanup_calls_at_capacity_max = 0,
164 };
165 pub const claim: alloc_phase.capacity.Declaration = .{
166 .source = .{
167 .id = "reticulum.paths",
168 .kind = .phase_static,
169 .limit_source = .caller,
170 .storage = .{
171 .covered = &.{.{
172 .id = "caller_path_table",
173 .lifetime = .transferred,
174 .detail = "caller storage for paths, random blobs, and announce payloads",
175 }},
176 .excluded = &.{
177 "borrowed announce payload inputs",
178 "persistent path records",
179 },
180 },
181 .capacity = .{
182 .inputs = &.{alloc_phase.capacity.bindInput(
183 Limits,
184 "paths_max",
185 "paths_max",
186 )},
187 .type_selectors = &.{alloc_phase.capacity.bindType(Entry, "path")},
188 .nodes = &.{
189 .{ .input = 0 },
190 .{ .scale = .{
191 .node = 0,
192 .coefficient = .{ .size_of_concrete_type = 0 },
193 } },
194 },
195 .assertions = &.{.{
196 .scope = .closure_total,
197 .measure = .retained,
198 .relation = .exact,
199 .expression = 1,
200 }},
201 },
202 .overload = .{
203 .kind = .not_applicable,
204 .detail = "a new path replaces a culled path, else the oldest timestamp",
205 },
206 .risks = .{
207 .transitive = .{
208 .status = .excluded,
209 .detail = "path operations call no allocating owner",
210 },
211 .foreign = .{
212 .status = .excluded,
213 .detail = "path storage crosses no foreign boundary",
214 },
215 },
216 .work = .{ .equation = "operations scan at most paths_max entries and 64 blobs" },
217 .obligations = &.{
218 .{ .key = "reticulum_paths_capacity", .role = .capacity_model },
219 .{ .key = "reticulum_paths_replace", .role = .overload },
220 .{ .key = "reticulum_paths_work", .role = .work_bound },
221 },
222 },
223 .bindings = .{
224 .owner = @This(),
225 .seal = .{
226 .family = alloc_phase.capacity.selector(@This().activate),
227 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
228 },
229 .teardown = .{
230 .family = alloc_phase.capacity.selector(@This().deinit),
231 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
232 },
233 },
234 };
235
236 pub fn init(storage: Storage, limits: Limits) InitError!Table {
237 const capacity = try Capacity.derive(limits);
238 if (storage.len != capacity.storage_bytes) return error.StorageLengthMismatch;
239 return .{
240 .phase = .initialization,
241 .capacity = capacity,
242 .storage = storage,
243 .entries = std.mem.bytesAsSlice(Entry, storage),
244 };
245 }
246
247 pub fn activate(self: *Table) void {
248 std.debug.assert(self.phase == .initialization);
249 std.debug.assert(self.len == 0);
250 self.phase = .steady;
251 }
252
253 /// Looks a destination up and hands back a path only while that path sits
254 /// inside the one-week age Reticulum@1.5.0 RNS/Transport.py:940-943 sets.
255 pub fn find(self: *Table, hash: [16]u8, now: Seconds) ?*Entry {
256 std.debug.assert(self.phase == .steady);
257 const entry = self.slot(hash) orelse return null;
258 if (!live(entry, now)) return null;
259 return entry;
260 }
261
262 /// Reports the path hop count, or the pathfinder maximum when the table
263 /// holds no live path, following Reticulum@1.5.0
264 /// RNS/Transport.py:3063-3070.
265 pub fn hopsTo(self: *Table, hash: [16]u8, now: Seconds) u8 {
266 const entry = self.find(hash, now) orelse return wire.pathfinder_hops;
267 std.debug.assert(entry.hops <= wire.pathfinder_hops);
268 return entry.hops;
269 }
270
271 /// Records an accepted announce, following Reticulum@1.5.0
272 /// RNS/Transport.py:2261-2265,2374-2375. An announce that wins back an
273 /// unresponsive path at an emission time the path already holds leaves the
274 /// unresponsive mark standing, so the next announce stamped at that instant
275 /// wins the path in turn. Every other accepted announce clears the mark,
276 /// following Reticulum@1.5.0 RNS/Transport.py:2155,2189,2200,2208-2212.
277 pub fn learn(self: *Table, candidate: Candidate, now: Seconds) *Entry {
278 std.debug.assert(self.phase == .steady);
279 std.debug.assert(candidate.hops <= wire.pathfinder_hops);
280 const previous = self.find(candidate.destination, now);
281 const target = previous orelse self.reclaim(candidate.destination, now);
282 if (previous == null) {
283 target.blob_count = 0;
284 target.blob_next = 0;
285 }
286 const repeats = previous != null and
287 now < target.expires and
288 emission(candidate.blob) == target.timebase();
289 if (!target.holdsBlob(candidate.blob)) {
290 target.blobs[target.blob_next] = candidate.blob;
291 target.blob_next = (target.blob_next + 1) % random_blobs_max;
292 target.blob_count = @min(target.blob_count + 1, random_blobs_max);
293 }
294 if (!repeats) target.state = .unknown;
295 target.destination = candidate.destination;
296 target.next_hop = candidate.next_hop;
297 target.announce_hash = candidate.announce_hash;
298 target.timestamp = now;
299 target.expires = now +| lifetime;
300 target.hops = candidate.hops;
301 target.carrier = candidate.carrier;
302 target.context_flag = candidate.context_flag;
303 const retained = candidate.payload.len <= target.payload_bytes.len;
304 target.payload_len = if (retained) @intCast(candidate.payload.len) else 0;
305 if (retained) @memcpy(target.payload_bytes[0..candidate.payload.len], candidate.payload);
306 std.debug.assert(self.find(candidate.destination, now) == target);
307 return target;
308 }
309
310 /// Notes that a link went unanswered over this path, as Reticulum@1.5.0
311 /// RNS/Transport.py:3157-3162 does. The answer says whether a path to that
312 /// destination was still inside its week.
313 pub fn markUnresponsive(self: *Table, hash: [16]u8, now: Seconds) bool {
314 std.debug.assert(self.phase == .steady);
315 const entry = self.find(hash, now) orelse return false;
316 entry.state = .unresponsive;
317 return true;
318 }
319
320 /// Throws a path away, leaving its destination open to the next announce
321 /// from any distance. Reticulum@1.5.0 RNS/Transport.py:3146-3154 reaches
322 /// the same end by zeroing the written-at second and running its removal
323 /// pass at once. This port zeroes the expiry because it runs no cull pass.
324 /// The call reports whether the table held a live path.
325 pub fn expire(self: *Table, hash: [16]u8, now: Seconds) bool {
326 std.debug.assert(self.phase == .steady);
327 const entry = self.find(hash, now) orelse return false;
328 entry.timestamp = 0;
329 entry.expires = 0;
330 return true;
331 }
332
333 pub fn count(self: *const Table) usize {
334 std.debug.assert(self.phase == .steady);
335 return self.len;
336 }
337
338 pub fn deinit(self: *Table) Storage {
339 std.debug.assert(self.phase == .steady);
340 self.phase = .teardown;
341 const storage = self.storage;
342 self.* = undefined;
343 return storage;
344 }
345
346 fn slot(self: *Table, hash: [16]u8) ?*Entry {
347 var found: ?*Entry = null;
348 for (self.entries[0..self.len]) |*entry| {
349 if (!std.mem.eql(u8, &entry.destination, &hash)) continue;
350 std.debug.assert(found == null);
351 found = entry;
352 }
353 return found;
354 }
355
356 fn reclaim(self: *Table, hash: [16]u8, now: Seconds) *Entry {
357 if (self.slot(hash)) |culled| return culled;
358 for (self.entries[0..self.len]) |*entry| {
359 if (!live(entry, now)) return entry;
360 }
361 if (self.len < self.capacity.paths_max) {
362 self.len += 1;
363 return &self.entries[self.len - 1];
364 }
365 var oldest = &self.entries[0];
366 for (self.entries[1..self.len]) |*entry| {
367 if (entry.timestamp < oldest.timestamp) oldest = entry;
368 }
369 return oldest;
370 }
371 };
372
373 comptime {
374 alloc_phase.capacity.requireProvisionedExactOwnerShape(Table);
375 }
376
377 fn blobAt(salt: u8, emitted: Seconds) Blob {
378 var blob: Blob = @splat(salt);
379 std.mem.writeInt(u40, blob[5..10], @intCast(emitted), .big);
380 return blob;
381 }
382
383 fn offer(destination_byte: u8, hops: u8, blob: Blob) Candidate {
384 return .{
385 .destination = @splat(destination_byte),
386 .next_hop = @splat(0xb0),
387 .announce_hash = @splat(destination_byte),
388 .hops = hops,
389 .carrier = 1,
390 .context_flag = 0,
391 .blob = blob,
392 .payload = "announce payload",
393 };
394 }
395
396 test "paths admit maximum and replace the oldest at maximum plus one" {
397 comptime {
398 @stardustClaim(alloc_phase.capacity.witness(
399 Table,
400 "reticulum_paths_capacity",
401 ), null, null, null, null, null, null);
402 @stardustClaim(alloc_phase.capacity.witness(
403 Table,
404 "reticulum_paths_replace",
405 ), null, null, null, null, null, null);
406 @stardustClaim(alloc_phase.capacity.witness(
407 Table,
408 "reticulum_paths_work",
409 ), null, null, null, null, null, null);
410 }
411 const capacity = comptime TableCapacity.derive(.{ .paths_max = 3 }) catch unreachable;
412 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
413 var table = try Table.init(&bytes, .{ .paths_max = 3 });
414 table.activate();
415 defer _ = table.deinit();
416 for (1..4) |value| {
417 const byte: u8 = @intCast(value);
418 _ = table.learn(offer(byte, 1, blobAt(byte, 100)), 100 + value);
419 }
420 try std.testing.expectEqual(@as(usize, 3), table.count());
421 _ = table.learn(offer(4, 1, blobAt(4, 200)), 200);
422 try std.testing.expectEqual(@as(usize, 3), table.count());
423 try std.testing.expect(table.find(@splat(1), 200) == null);
424 for (2..5) |value| try std.testing.expect(table.find(@splat(@intCast(value)), 200) != null);
425 }
426
427 test "paths reclaim a culled slot before the oldest live path" {
428 const capacity = comptime TableCapacity.derive(.{ .paths_max = 2 }) catch unreachable;
429 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
430 var table = try Table.init(&bytes, .{ .paths_max = 2 });
431 table.activate();
432 defer _ = table.deinit();
433 _ = table.learn(offer(1, 1, blobAt(1, 1)), 10);
434 _ = table.learn(offer(2, 1, blobAt(2, 1)), 5);
435 const later: Seconds = 10 + lifetime;
436 try std.testing.expect(table.find(@splat(2), later) == null);
437 _ = table.learn(offer(3, 1, blobAt(3, 1)), later);
438 try std.testing.expect(table.find(@splat(1), later) != null);
439 try std.testing.expect(table.find(@splat(3), later) != null);
440 try std.testing.expectEqual(@as(usize, 2), table.count());
441 }
442
443 test "Reticulum@1.5.0 RNS/Transport.py:2137-2213 path add rules" {
444 const capacity = comptime TableCapacity.derive(.{ .paths_max = 1 }) catch unreachable;
445 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
446 var table = try Table.init(&bytes, .{ .paths_max = 1 });
447 table.activate();
448 defer _ = table.deinit();
449 const accepted_at: Seconds = 1_000;
450 try std.testing.expect(admits(null, offer(7, 3, blobAt(1, 500)), accepted_at));
451 const entry = table.learn(offer(7, 3, blobAt(1, 500)), accepted_at);
452 try std.testing.expectEqual(accepted_at + lifetime, entry.expires);
453 try std.testing.expect(admits(entry, offer(7, 3, blobAt(2, 501)), accepted_at));
454 try std.testing.expect(admits(entry, offer(7, 2, blobAt(2, 501)), accepted_at));
455 try std.testing.expect(!admits(entry, offer(7, 3, blobAt(1, 500)), accepted_at));
456 try std.testing.expect(!admits(entry, offer(7, 3, blobAt(2, 500)), accepted_at));
457 try std.testing.expect(!admits(entry, offer(7, 2, blobAt(2, 499)), accepted_at));
458 const before = entry.expires - 1;
459 try std.testing.expect(admits(entry, offer(7, 4, blobAt(2, 501)), before));
460 try std.testing.expect(!admits(entry, offer(7, 4, blobAt(2, 500)), before));
461 try std.testing.expect(!admits(entry, offer(7, 4, blobAt(2, 499)), before));
462 try std.testing.expect(admits(entry, offer(7, 4, blobAt(2, 499)), entry.expires));
463 try std.testing.expect(!admits(entry, offer(7, 4, blobAt(1, 500)), entry.expires));
464 }
465
466 test "Reticulum@1.5.0 RNS/Transport.py:2263-2265 evicts the oldest of 65 blobs" {
467 const capacity = comptime TableCapacity.derive(.{ .paths_max = 1 }) catch unreachable;
468 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
469 var table = try Table.init(&bytes, .{ .paths_max = 1 });
470 table.activate();
471 defer _ = table.deinit();
472 const now: Seconds = 5_000;
473 for (0..65) |index| {
474 const offered = offer(9, 1, blobAt(@intCast(index), 1_000 + index));
475 try std.testing.expect(admits(table.find(offered.destination, now), offered, now));
476 _ = table.learn(offered, now);
477 }
478 const entry = table.find(@splat(9), now).?;
479 try std.testing.expectEqual(random_blobs_max, entry.blob_count);
480 try std.testing.expect(!entry.holdsBlob(blobAt(0, 1_000)));
481 try std.testing.expect(entry.holdsBlob(blobAt(1, 1_001)));
482 try std.testing.expect(entry.holdsBlob(blobAt(64, 1_064)));
483 try std.testing.expectEqual(@as(Seconds, 1_064), entry.timebase());
484 }
485
486 test "Reticulum@1.5.0 RNS/Transport.py:940-943 culls a path one second after a week" {
487 const capacity = comptime TableCapacity.derive(.{ .paths_max = 1 }) catch unreachable;
488 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
489 var table = try Table.init(&bytes, .{ .paths_max = 1 });
490 table.activate();
491 defer _ = table.deinit();
492 _ = table.learn(offer(5, 2, blobAt(5, 10)), 100);
493 try std.testing.expectEqual(@as(u8, 2), table.hopsTo(@splat(5), 100 + lifetime));
494 const culled_at: Seconds = 100 + lifetime + 1;
495 try std.testing.expect(table.find(@splat(5), culled_at) == null);
496 try std.testing.expectEqual(wire.pathfinder_hops, table.hopsTo(@splat(5), culled_at));
497 const replay = offer(5, 3, blobAt(5, 10));
498 try std.testing.expect(admits(table.find(replay.destination, culled_at), replay, culled_at));
499 const entry = table.learn(replay, culled_at);
500 try std.testing.expectEqual(@as(u8, 1), entry.blob_count);
501 try std.testing.expectEqual(@as(usize, 1), table.count());
502 }
503
504 test "Reticulum@1.5.0 RNS/Transport.py:2208-2212 an unresponsive path takes its announce again" {
505 const capacity = comptime TableCapacity.derive(.{ .paths_max = 1 }) catch unreachable;
506 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
507 var table = try Table.init(&bytes, .{ .paths_max = 1 });
508 table.activate();
509 defer _ = table.deinit();
510 const now: Seconds = 1_000;
511 const entry = table.learn(offer(7, 2, blobAt(1, 500)), now);
512 const farther = offer(7, 3, blobAt(1, 500));
513 try std.testing.expectEqual(State.unknown, entry.state);
514 try std.testing.expect(!admits(entry, farther, now));
515 try std.testing.expect(table.markUnresponsive(@splat(7), now));
516 try std.testing.expectEqual(State.unresponsive, entry.state);
517 try std.testing.expect(admits(entry, farther, now));
518 try std.testing.expect(!admits(entry, offer(7, 2, blobAt(1, 500)), now));
519 try std.testing.expect(!admits(entry, offer(7, 3, blobAt(2, 499)), now));
520 try std.testing.expect(admits(entry, offer(7, 3, blobAt(2, 500)), now));
521 _ = table.learn(farther, now + 1);
522 try std.testing.expectEqual(State.unresponsive, entry.state);
523 try std.testing.expectEqual(@as(u8, 1), entry.blob_count);
524 try std.testing.expectEqual(@as(u8, 3), entry.hops);
525 try std.testing.expect(!admits(entry, farther, now + 1));
526 try std.testing.expect(admits(entry, offer(7, 4, blobAt(1, 500)), now + 1));
527 _ = table.learn(offer(7, 3, blobAt(2, 700)), now + 2);
528 try std.testing.expectEqual(State.unknown, entry.state);
529 try std.testing.expect(!admits(entry, offer(7, 4, blobAt(1, 500)), now + 2));
530 }
531
532 test "Reticulum@1.5.0 RNS/Transport.py:3146-3154 expires a path so any announce replaces it" {
533 const capacity = comptime TableCapacity.derive(.{ .paths_max = 1 }) catch unreachable;
534 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
535 var table = try Table.init(&bytes, .{ .paths_max = 1 });
536 table.activate();
537 defer _ = table.deinit();
538 const now: Seconds = 1_000;
539 _ = table.learn(offer(7, 1, blobAt(1, 500)), now);
540 try std.testing.expect(table.expire(@splat(7), now));
541 try std.testing.expect(table.find(@splat(7), now) == null);
542 try std.testing.expect(table.find(@splat(7), now + lifetime + 1) == null);
543 try std.testing.expect(!table.expire(@splat(7), now));
544 try std.testing.expect(!table.markUnresponsive(@splat(7), now));
545 try std.testing.expectEqual(wire.pathfinder_hops, table.hopsTo(@splat(7), now));
546 const older = offer(7, 5, blobAt(2, 1));
547 try std.testing.expect(admits(table.find(older.destination, now), older, now));
548 const relearned = table.learn(older, now);
549 try std.testing.expectEqual(@as(u8, 5), relearned.hops);
550 try std.testing.expectEqual(@as(u8, 1), relearned.blob_count);
551 try std.testing.expectEqual(@as(usize, 1), table.count());
552 }
553
554 test "paths copy announce payloads up to the HEADER_2 relay bound" {
555 const capacity = comptime TableCapacity.derive(.{ .paths_max = 2 }) catch unreachable;
556 var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
557 var table = try Table.init(&bytes, .{ .paths_max = 2 });
558 table.activate();
559 defer _ = table.deinit();
560 var long: [destination.announce.payload_bytes_max + 1]u8 = @splat(0x5a);
561 var offered = offer(1, 1, blobAt(1, 1));
562 offered.payload = long[0..destination.announce.payload_bytes_max];
563 const kept = table.learn(offered, 1);
564 long[0] = 0;
565 try std.testing.expectEqual(@as(usize, long.len - 1), kept.announcePayload().?.len);
566 try std.testing.expectEqual(@as(u8, 0x5a), kept.announcePayload().?[0]);
567 offered = offer(2, 1, blobAt(2, 1));
568 offered.payload = &long;
569 const skipped = table.learn(offered, 1);
570 try std.testing.expect(skipped.announcePayload() == null);
571 try std.testing.expectEqual(@as(u8, 1), skipped.hops);
572 }