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 }