prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! RNS 1.4.2 Resources: the sender seals the whole stream once under the session key and slices the ciphertext into parts; the receiver pulls parts by 4-byte map hashes inside a sliding window. The link is the authentication; no signature rides the transfer.

pub mod advertisement;
pub mod assemble_incoming;
pub mod assembly;
pub mod build_outgoing;
pub mod control;
pub mod receive;
pub mod send;
mod send_plan;
pub mod serve_outgoing;
pub mod streamed_open;
pub mod table;

pub use send_plan::{ResourceSegmentPlan, ResourceSendPlan, ResourceSendPlanError};

use crate::crypto::SHA256_OUTPUT_LEN;
use crate::engine::CommandId;
use crate::routing::links::data::LINK_MDU;
use crate::routing::links::request::RequestId;
use crate::routing::links::LinkId;
use crate::wire::{BROADCAST_MTU, HEADER_MAX_LEN, IFAC_MIN_LEN};
use sha2::{Digest, Sha256};

/// RNS 1.4.2 `Resource.MAPHASH_LEN`: a part is named by the first four bytes of `full_hash(part ‖ salt nonce)`.
pub const MAP_HASH_LEN: usize = 4;

/// RNS 1.4.2 `Resource.RANDOM_HASH_SIZE`.
///
/// It turns out the reference's `random_hash`es are not hashes after all. Instead, they are TWO distinct nonces of this size:
/// - the stream nonce (sealed ahead of the payload, discarded on assembly), and
/// - the salt nonce (the advertisement's `r`, salting every map hash).
///
/// Only the salt nonce re-rolls on collision.
pub const RESOURCE_NONCE_LEN: usize = 4;

/// A resource names itself by a full SHA-256. RNS 1.4.2 `Identity.full_hash(data + random_hash)`
pub const RESOURCE_HASH_LEN: usize = SHA256_OUTPUT_LEN;

/// RNS 1.4.2 `Resource.WINDOW_MAX` (= `WINDOW_MAX_FAST`). The widest part window either end will ever run. The collision guard is sized from it.
pub const WINDOW_MAX: usize = 75;

/// RNS 1.4.2 `Resource.WINDOW`
pub const WINDOW_START: usize = 4;

/// RNS 1.4.2 `Resource.WINDOW_MIN`.
pub const WINDOW_MIN: usize = 2;

/// RNS 1.4.2 `Resource.WINDOW_MAX_SLOW`. The ceiling a window grows toward until the link proves fast enough to lift it to [`WINDOW_MAX`].
pub const WINDOW_MAX_SLOW: usize = 10;

/// RNS 1.4.2 `Resource.WINDOW_FLEXIBILITY`. How far the window may run ahead of its floor before the floor follows it up.
pub const WINDOW_FLEXIBILITY: usize = 4;

/// RNS 1.4.2 `Resource.MAX_RETRIES`
pub const PART_REQUEST_MAX_RETRIES: u8 = 16;

/// RNS 1.4.2 `Resource.MAX_ADV_RETRIES`
pub const MAX_ADVERTISEMENT_RETRIES: u8 = 4;

/// RNS 1.4.2 `Resource.PART_TIMEOUT_FACTOR`. The rtt multiple a receiver waits on outstanding parts before retrying.
pub const PART_TIMEOUT_FACTOR: u64 = 4;

/// RNS 1.4.2 `Resource.PART_TIMEOUT_FACTOR_AFTER_RTT`.
pub const PART_TIMEOUT_FACTOR_AFTER_RTT: u64 = 2;

/// RNS 1.4.2 `Resource.RATE_FAST` (50 kbps as bytes/s). The measured rate past which a round counts toward lifting the window ceiling to  [`WINDOW_MAX`].
pub const RATE_FAST_BYTES_PER_SECOND: u64 = 50 * 1000 / 8;

/// RNS 1.4.2 `Resource.RATE_VERY_SLOW` (2 kbps as bytes/s): the measured rate below which a round counts toward dropping the ceiling to [`WINDOW_MAX_VERY_SLOW`].
pub const RATE_VERY_SLOW_BYTES_PER_SECOND: u64 = 2 * 1000 / 8;

/// RNS 1.4.2 `Resource.WINDOW_MAX_VERY_SLOW`.
pub const WINDOW_MAX_VERY_SLOW: usize = 4;

/// RNS 1.4.2 `Resource.FAST_RATE_THRESHOLD` (`WINDOW_MAX_SLOW - WINDOW - 2` = 4). How many fast rounds earn the lift.
pub const FAST_RATE_THRESHOLD: u8 = (WINDOW_MAX_SLOW - WINDOW_START - 2) as u8;

/// RNS 1.4.2 `Resource.VERY_SLOW_RATE_THRESHOLD`. How many very-slow rounds (with no fast round ever seen) drop the ceiling.
pub const VERY_SLOW_RATE_THRESHOLD: u8 = 2;

/// LINKREQUEST (86) + LRPROOF (118) + LRRTT (83) at the broadcast MTU.
/// The reference accumulates actual lengths (`Link.establishment_cost`); ours pins the deterministic total, which seeds only the first rate estimate and converges identically from round one.
pub const ESTABLISHMENT_COST_ESTIMATE_BYTES: u64 = 86 + 118 + 83;

/// RNS 1.4.2 `Resource.PROOF_TIMEOUT_FACTOR`. The smaller rtt multiple a sender waits on the proof.
pub const PROOF_TIMEOUT_FACTOR: u64 = 3;

/// RNS 1.4.2 `Resource.PROCESSING_GRACE`
pub const PROCESSING_GRACE_MS: u64 = 1_000;

/// RNS 1.4.2 `Resource.RETRY_GRACE_TIME`
pub const RETRY_GRACE_MS: u64 = 250;

/// RNS 1.4.2 `Resource.PER_RETRY_DELAY`
pub const PER_RETRY_DELAY_MS: u64 = 500;

/// RNS 1.4.2 `Resource.SENDER_GRACE_TIME`
pub const SENDER_GRACE_MS: u64 = 10_000;

/// Our seam, no reference analog: how long a transfer may sit at `AwaitingDecompression` before the receiver gives up on its host's inflate.
/// A host that can inflate answers in milliseconds; one that cannot would otherwise pin the table slot and the link's one-resource lane forever.
pub const DECOMPRESSION_GRACE_MS: u64 = 10_000;

/// Our seam, no reference analog: how long a complete transfer may sit at `AwaitingOpen` before the receiver gives up on its pool's span verdict.
/// A live worker answers in milliseconds; a dead pool would otherwise pin the slot and the link's one-resource lane forever.
pub const OPEN_VERDICT_GRACE_MS: u64 = 1_000;

/// RNS 1.4.2 `ResourceAdvertisement.OVERHEAD`: the byte budget the reference reserves for everything in a packed advertisement except the map hashes.
pub const ADVERTISEMENT_OVERHEAD: usize = 134;

/// RNS 1.4.2 `ResourceAdvertisement.HASHMAP_MAX_LEN` (74): how many map hashes ride one advertisement or one hashmap update.
///
/// Derived from the base link MDU (431), never the negotiated one, so every link lands on the same figure regardless of its MTU.
pub const HASHMAP_MAX_LEN: usize = (LINK_MDU - ADVERTISEMENT_OVERHEAD) / MAP_HASH_LEN;

/// RNS 1.4.2 `ResourceAdvertisement.COLLISION_GUARD_SIZE` (224): The sliding span of parts within which two map hashes must not collide.
/// The sender re-rolls its salt nonce until they don't.
pub const COLLISION_GUARD_SIZE: usize = 2 * WINDOW_MAX + HASHMAP_MAX_LEN;

/// RNS 1.4.2 `Resource.MAX_EFFICIENT_SIZE` (1 MiB − 1). The most one segment carries.
///
/// Anything larger splits into segments of this size, each transferred as its own resource sharing the first segment's hash.
pub const MAX_EFFICIENT_SIZE: usize = 1024 * 1024 - 1;

/// RNS 1.4.2 `Resource.METADATA_MAX_SIZE` (16 MiB − 1)
pub const METADATA_MAX_SIZE: usize = 16 * 1024 * 1024 - 1;

/// The length prefix ahead of the packed metadata in the stream: `struct.pack(">I", metadata_size)[1:]` (RNS 1.4.2 `Resource.__init__`).
pub const METADATA_PREFIX_LEN: usize = 3;

/// RNS 1.4.2 `Resource.sdu`
pub const fn resource_sdu(mtu: usize) -> usize {
    mtu - HEADER_MAX_LEN - IFAC_MIN_LEN
}

/// IV ‖ PKCS#7-padded(stream nonce ‖ stream) ‖ MAC.
pub const fn sealed_transfer_bytes(stream_len: usize) -> usize {
    let padded = ((stream_len + RESOURCE_NONCE_LEN) / 16 + 1) * 16;
    16 + padded + 32
}

/// The part count at the broadcast-MTU sdu. This is the floor every link clears, so the worst case a store must name.
pub const fn max_part_count(transfer_capacity: usize) -> usize {
    transfer_capacity.div_ceil(resource_sdu(BROADCAST_MTU))
}

/// The most frames one inbound resource request can synchronously ask the engine to emit.
///
/// A request names at most [`WINDOW_MAX`] existing parts. When the receiver has exhausted its
/// current hashmap segment, the same reaction can append one hashmap update. Storage recipes that
/// cannot hold a full window need capacity only for every part they can actually materialize plus
/// that update.
pub const fn max_outgoing_resource_reaction_frames(transfer_capacity: usize) -> usize {
    let parts = max_part_count(transfer_capacity);
    if parts < WINDOW_MAX {
        parts + 1
    } else {
        WINDOW_MAX + 1
    }
}

#[cfg(test)]
mod reaction_capacity_tests {
    use super::*;

    #[test]
    fn outbound_reaction_capacity_tracks_small_stores_and_caps_at_one_full_window() {
        let part = resource_sdu(BROADCAST_MTU);
        assert_eq!(max_outgoing_resource_reaction_frames(0), 1);
        assert_eq!(max_outgoing_resource_reaction_frames(18 * part), 19);
        assert_eq!(max_outgoing_resource_reaction_frames(WINDOW_MAX * part), 76);
        assert_eq!(max_outgoing_resource_reaction_frames(100 * part), 76);
    }
}

/// RNS 1.4.2 `Resource.get_map_hash`; the four-byte name a part is requested by `full_hash(part ‖ salt nonce)` truncated.
pub fn map_hash(part: &[u8], salt_nonce: &SaltNonce) -> [u8; MAP_HASH_LEN] {
    let mut hasher = Sha256::new();
    hasher.update(part);
    hasher.update(salt_nonce.as_bytes());
    let digest = hasher.finalize();
    [digest[0], digest[1], digest[2], digest[3]]
}

pub(crate) fn map_hash_name_word(name: &[u8]) -> u32 {
    u32::from_ne_bytes([name[0], name[1], name[2], name[3]])
}

/// The advertisement's `r`; the reference calls it `random_hash` but it's truly a nonce, not a hash.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SaltNonce([u8; RESOURCE_NONCE_LEN]);

impl SaltNonce {
    #[must_use]
    pub const fn new(bytes: [u8; RESOURCE_NONCE_LEN]) -> Self {
        Self(bytes)
    }

    #[must_use]
    pub const fn as_bytes(&self) -> &[u8; RESOURCE_NONCE_LEN] {
        &self.0
    }
}

/// RNS 1.4.2 `Link.resource_strategy`, engine-gated: the reference's unbounded `ACCEPT_ALL` becomes an accept with enforced bounds, refused at the advertisement gate before a single part moves.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ResourceStrategy {
    /// Every link is born refusing resources; RNS 1.4.2 `Link.__init__` sets `ACCEPT_NONE`.
    #[default]
    AcceptNone,
    Accept {
        max_uncompressed_bytes: u64,
        accept_compressed: bool,
    },
    /// RNS 1.4.2 `ACCEPT_APP`: the host's decider judges each unsolicited advertisement from its [`ResourceOffer`].
    /// A declined offer answers with a receiver-cancel — the reference's `Resource.reject` — so the sender settles instead of timing out.
    AcceptIf,
}

/// RNS 1.4.2 hands the `ACCEPT_APP` callback the parsed advertisement; this is that view: one unsolicited segment's facts, judged before a single part moves.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceOffer {
    pub link_id: LinkId,
    pub remote_identity: Option<crate::identity::IdentityHash>,
    pub hash: ResourceHash,
    /// The advertised `d`: on a split transfer this is the WHOLE transfer's uncompressed length, on every segment.
    pub uncompressed_data_bytes: u64,
    /// This segment's sealed stream length on the wire.
    pub sealed_transfer_bytes: usize,
    pub part_count: usize,
    pub segment_index: u64,
    pub total_segment_count: u64,
    pub compression: ResourceCompression,
    pub has_metadata: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceCompression {
    Uncompressed,
    Bz2,
}

impl ResourceCompression {
    #[must_use]
    pub const fn wire_flag(self) -> bool {
        match self {
            Self::Uncompressed => false,
            Self::Bz2 => true,
        }
    }

    /// bz2 is the only compression RNS 1.4.2 can mean by the `c` flag.
    #[must_use]
    pub const fn from_wire_flag(compressed: bool) -> Self {
        if compressed {
            Self::Bz2
        } else {
            Self::Uncompressed
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceSend<'a> {
    pub id: CommandId,
    pub link_id: LinkId,
    pub body: ResourceBody<'a>,
    pub correlation: ResourceCorrelation,
}

/// The reference's keep-only-if-smaller rule picks between payload and precompressed attempt at buildup time; a host that links no compressor just passes `None`.
///
/// `metadata` rides ahead of `data` in the stream (so a `compressed_candidate` must be compressed over `metadata_block ‖ data`, exactly the composite the reference feeds bz2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceBody<'a> {
    pub data: &'a [u8],
    pub compressed_candidate: Option<&'a [u8]>,
    pub metadata: ResourceMetadata<'a>,
}

/// RNS 1.4.2 resource metadata on the send side: packed msgpack bytes the engine never unpacks,  carried at the head of the stream as `3-byte-BE-length ‖ packed` and covered by the resource hash, the advertised `d`, and (when one wins) the compressed stream.
///
/// On a split transfer the block rides segment one only, but every segment advertises the metadata flag and a `d` that includes the block.
/// The reference threads this through `sent_metadata_size`; [`ResourceMetadata::SentInFirstSegment`] is that parameter by name.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ResourceMetadata<'a> {
    #[default]
    None,
    /// This (first or only) segment carries the block in-stream.
    Packed(&'a [u8]),
    /// A later segment of a split whose first segment carried the block: the flag and the `d` accounting still travel, the bytes do not.
    SentInFirstSegment { packed_len: u32 },
}

impl<'a> ResourceMetadata<'a> {
    /// Prefix plus packed bytes: what the block adds to the advertised `d` on every segment.
    #[must_use]
    pub const fn block_len(&self) -> usize {
        match self {
            Self::None => 0,
            Self::Packed(packed) => METADATA_PREFIX_LEN + packed.len(),
            Self::SentInFirstSegment { packed_len } => METADATA_PREFIX_LEN + *packed_len as usize,
        }
    }

    /// Whether the advertisement's metadata flag travels.
    #[must_use]
    pub const fn travels(&self) -> bool {
        !matches!(self, Self::None)
    }
}

/// RNS 1.4.2 advertises `(segment_index, total_segments)` plus the whole transfer's uncompressed length (the `d` field) on every segment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceSegment {
    pub index: u64,
    pub total_segments: u64,
    pub total_data_bytes: u64,
}

impl ResourceSegment {
    #[must_use]
    pub fn whole(data_len: u64) -> Self {
        Self {
            index: 1,
            total_segments: 1,
            total_data_bytes: data_len,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourcePartRequest<'a> {
    pub link_id: LinkId,
    pub hash: ResourceHash,
    pub requested: &'a [u8],
    pub last_known_map_hash: Option<[u8; MAP_HASH_LEN]>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ResourceCorrelation {
    #[default]
    Unsolicited,
    Request {
        id: RequestId,
        response_timeout: crate::engine::RequestResponseTimeout,
        maximum_response_bytes: crate::units::ByteLimit,
    },
    Response(RequestId),
}

impl ResourceCorrelation {
    #[must_use]
    pub fn request_id(self) -> Option<RequestId> {
        match self {
            Self::Unsolicited => None,
            Self::Request { id, .. } | Self::Response(id) => Some(id),
        }
    }

    #[must_use]
    pub const fn is_request(self) -> bool {
        matches!(self, Self::Request { .. })
    }

    #[must_use]
    pub const fn is_response(self) -> bool {
        matches!(self, Self::Response(_))
    }
}

/// Why an incoming transfer died.
/// The reference has no analogous signal. A stock RNS receiver's failures surface only in its own logs; ours ride the failure event by name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceFailureCause {
    CancelledBySender,
    RefusedHashmapUpdate(table::ApplyHashmapUpdateError),
    RetriesExhausted,
    LinkVanished,
    TransferUnopenable,
    TransferCorrupt,
    ProofUnsendable,
    DecompressionFailed,
    DecompressionTimedOut,
    OpenTimedOut,
    /// The verified stream's own metadata prefix declares more bytes than the stream holds.
    /// Intentional deviation: the reference silently delivers truncated metadata and empty data when the declared length overruns (Python slice leniency); we fail the transfer by name.
    MetadataOverrun,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceHash([u8; RESOURCE_HASH_LEN]);

impl ResourceHash {
    #[must_use]
    pub const fn new(bytes: [u8; RESOURCE_HASH_LEN]) -> Self {
        Self(bytes)
    }

    #[must_use]
    pub const fn as_bytes(&self) -> &[u8; RESOURCE_HASH_LEN] {
        &self.0
    }
}

/// RNS 1.4.2 `expected_proof = Identity.full_hash(data + hash)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceProof([u8; RESOURCE_HASH_LEN]);

impl ResourceProof {
    #[must_use]
    pub const fn new(bytes: [u8; RESOURCE_HASH_LEN]) -> Self {
        Self(bytes)
    }

    #[must_use]
    pub const fn as_bytes(&self) -> &[u8; RESOURCE_HASH_LEN] {
        &self.0
    }
}