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 }