lib/reticulum/src/destination/registry.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const alloc_phase = @import("alloc_phase");
  3 const destination = @import("root.zig");
  4 
  5 /// The choice of what a registered destination does with a packet delivered to
  6 /// it: prove nothing, hand the decision to the application, or prove it at
  7 /// once, so a caller can say what the node does with the packet, following
  8 /// Reticulum@1.5.0 RNS/Destination.py:69-71.
  9 pub const ProofStrategy = enum(u8) {
 10     none = 0x21,
 11     app = 0x22,
 12     all = 0x23,
 13 };
 14 
 15 /// The entry a caller fills in to register a destination the node will answer
 16 /// for: the 16-byte address, the announced name hash, the kind of destination,
 17 /// the proof strategy, which of the node's private identities signs for it, and
 18 /// a group key. A group destination shares one 64-byte key, which its members
 19 /// use as a token key directly, following Reticulum@1.5.0
 20 /// RNS/Destination.py:544-558 and Reticulum@1.5.0
 21 /// RNS/Cryptography/Token.py:53-55.
 22 pub const Entry = struct {
 23     hash: [16]u8,
 24     /// The ten bytes an announce publishes for this destination's dotted name,
 25     /// set so the node can announce the destination under the right published
 26     /// name and worked out separately from the address, following
 27     /// Reticulum@1.5.0 RNS/Destination.py:188-190.
 28     name_hash: [10]u8 = @splat(0),
 29     kind: destination.Type,
 30     proof_strategy: ProofStrategy,
 31     identity_index: ?usize = null,
 32     group_key: [64]u8 = @splat(0),
 33 };
 34 
 35 const TableLimits = struct {
 36     destinations_max: usize,
 37 };
 38 
 39 const TableCapacity = struct {
 40     destinations_max: usize,
 41     storage_bytes: usize,
 42 
 43     pub const DeriveError = error{ InvalidLimit, CapacityOverflow };
 44 
 45     pub fn derive(limits: TableLimits) DeriveError!TableCapacity {
 46         if (limits.destinations_max == 0) return error.InvalidLimit;
 47         const destinations_max = limits.destinations_max;
 48         const storage_bytes = alloc_phase.capacity.mul(
 49             usize,
 50             destinations_max,
 51             @sizeOf(Entry),
 52         ) catch return error.CapacityOverflow;
 53         return .{ .destinations_max = destinations_max, .storage_bytes = storage_bytes };
 54     }
 55 };
 56 
 57 /// The bounded list of destinations one node answers for, for a caller that
 58 /// hands the node one block of storage and gets it back at teardown, following
 59 /// Reticulum@1.5.0 RNS/Transport.py:2834-2845. The caller fixes how many
 60 /// destinations the list holds, and the storage it hands over has to equal the
 61 /// byte count derived from that number, or registration returns
 62 /// `error.StorageLengthMismatch`. Registering an address the list already holds
 63 /// returns `error.Duplicate`, and registering into a full list returns
 64 /// `error.Full`, both before the list changes. Looking a destination up scans
 65 /// the list, so a lookup costs one pass over it.
 66 pub const Table = struct {
 67     phase: alloc_phase.capacity.Phase,
 68     capacity: Capacity,
 69     storage: Storage,
 70     entries: []Entry,
 71     len: usize = 0,
 72 
 73     pub const storage_alignment: usize = 8;
 74     pub const Storage = []align(storage_alignment) u8;
 75     pub const Limits: type = TableLimits;
 76     pub const Capacity: type = TableCapacity;
 77     pub const Exhaustion = error{ Full, Duplicate };
 78     pub const InitError = Capacity.DeriveError || error{StorageLengthMismatch};
 79     pub const work_limits: alloc_phase.capacity.WorkLimits = .{
 80         .transition_steps_max = 65_536,
 81         .cleanup_steps_per_call_max = 0,
 82         .cleanup_calls_at_capacity_max = 0,
 83     };
 84     pub const claim: alloc_phase.capacity.Declaration = .{
 85         .source = .{
 86             .id = "reticulum.destinations",
 87             .kind = .phase_static,
 88             .limit_source = .caller,
 89             .storage = .{
 90                 .covered = &.{.{
 91                     .id = "caller_local_destination_table",
 92                     .lifetime = .transferred,
 93                     .detail = "caller storage for registered local destinations",
 94                 }},
 95                 .excluded = &.{
 96                     "private identity storage selected by identity indexes",
 97                     "application handlers and persistence effects",
 98                 },
 99             },
100             .capacity = .{
101                 .inputs = &.{alloc_phase.capacity.bindInput(
102                     Limits,
103                     "destinations_max",
104                     "destinations_max",
105                 )},
106                 .type_selectors = &.{alloc_phase.capacity.bindType(Entry, "destination")},
107                 .nodes = &.{
108                     .{ .input = 0 },
109                     .{ .scale = .{
110                         .node = 0,
111                         .coefficient = .{ .size_of_concrete_type = 0 },
112                     } },
113                 },
114                 .assertions = &.{.{
115                     .scope = .closure_total,
116                     .measure = .retained,
117                     .relation = .exact,
118                     .expression = 1,
119                 }},
120             },
121             .overload = .{
122                 .kind = .reject_before_mutation,
123                 .detail = "duplicate and full registration preserve the table",
124             },
125             .risks = .{
126                 .transitive = .{
127                     .status = .excluded,
128                     .detail = "destination registration calls no allocating owner",
129                 },
130                 .foreign = .{
131                     .status = .excluded,
132                     .detail = "destination registration crosses no foreign boundary",
133                 },
134             },
135             .work = .{ .equation = "registration scans at most destinations_max entries" },
136             .obligations = &.{
137                 .{ .key = "reticulum_destinations_capacity", .role = .capacity_model },
138                 .{ .key = "reticulum_destinations_overload", .role = .overload },
139                 .{ .key = "reticulum_destinations_work", .role = .work_bound },
140             },
141         },
142         .bindings = .{
143             .owner = @This(),
144             .seal = .{
145                 .family = alloc_phase.capacity.selector(@This().activate),
146                 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
147             },
148             .teardown = .{
149                 .family = alloc_phase.capacity.selector(@This().deinit),
150                 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
151             },
152         },
153     };
154 
155     pub fn init(storage: Storage, limits: Limits) InitError!Table {
156         const capacity = try Capacity.derive(limits);
157         if (storage.len != capacity.storage_bytes) return error.StorageLengthMismatch;
158         return .{
159             .phase = .initialization,
160             .capacity = capacity,
161             .storage = storage,
162             .entries = std.mem.bytesAsSlice(Entry, storage),
163         };
164     }
165 
166     pub fn activate(self: *Table) void {
167         std.debug.assert(self.phase == .initialization);
168         std.debug.assert(self.len == 0);
169         self.phase = .steady;
170     }
171 
172     pub fn register(self: *Table, entry: Entry) Exhaustion!void {
173         std.debug.assert(self.phase == .steady);
174         if (self.find(entry.hash) != null) return error.Duplicate;
175         if (self.len == self.capacity.destinations_max) return error.Full;
176         self.entries[self.len] = entry;
177         self.len += 1;
178     }
179 
180     pub fn find(self: *const Table, hash: [16]u8) ?*const Entry {
181         std.debug.assert(self.phase == .steady);
182         for (self.entries[0..self.len]) |*entry| {
183             if (std.mem.eql(u8, &entry.hash, &hash)) return entry;
184         }
185         return null;
186     }
187 
188     pub fn count(self: *const Table) usize {
189         std.debug.assert(self.phase == .steady);
190         return self.len;
191     }
192 
193     pub fn deinit(self: *Table) Storage {
194         std.debug.assert(self.phase == .steady);
195         self.phase = .teardown;
196         const storage = self.storage;
197         self.* = undefined;
198         return storage;
199     }
200 };
201 
202 comptime {
203     alloc_phase.capacity.requireProvisionedRejectingOwnerShape(Table);
204 }
205 
206 fn testEntry(value: u8) Entry {
207     return .{
208         .hash = @splat(value),
209         .kind = .single,
210         .proof_strategy = .all,
211         .identity_index = value,
212     };
213 }
214 
215 test "destinations admit maximum and reject maximum plus one" {
216     comptime {
217         @stardustClaim(alloc_phase.capacity.witness(
218             Table,
219             "reticulum_destinations_capacity",
220         ), null, null, null, null, null, null);
221         @stardustClaim(alloc_phase.capacity.witness(
222             Table,
223             "reticulum_destinations_overload",
224         ), null, null, null, null, null, null);
225         @stardustClaim(alloc_phase.capacity.witness(
226             Table,
227             "reticulum_destinations_work",
228         ), null, null, null, null, null, null);
229     }
230     const capacity = comptime TableCapacity.derive(.{ .destinations_max = 3 }) catch unreachable;
231     var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
232     var table = try Table.init(&bytes, .{ .destinations_max = 3 });
233     table.activate();
234     defer _ = table.deinit();
235     for (1..4) |value| try table.register(testEntry(@intCast(value)));
236     try std.testing.expectError(error.Full, table.register(testEntry(4)));
237     try std.testing.expectEqual(@as(usize, 3), table.count());
238 }
239 
240 test "destinations reject duplicate registration without mutation" {
241     const capacity = comptime TableCapacity.derive(.{ .destinations_max = 3 }) catch unreachable;
242     var bytes: [capacity.storage_bytes]u8 align(Table.storage_alignment) = undefined;
243     var table = try Table.init(&bytes, .{ .destinations_max = 3 });
244     table.activate();
245     defer _ = table.deinit();
246     try table.register(testEntry(1));
247     try std.testing.expectError(error.Duplicate, table.register(testEntry(1)));
248     try std.testing.expectEqual(@as(usize, 1), table.count());
249 }