Skip to main content

offline_protocol/
config.rs

1//! Protocol configuration.
2
3use crate::constants::DEFAULT_INITIAL_TTL;
4use crate::protocol::mesh_relay::{MeshRelayConfig, RELAY_QUEUE_MAX_OVERDUE};
5use offline_protocol_reliability::{AckConfig, DeduplicatorConfig, RetryConfig};
6use offline_protocol_router::{DorsConfig, RelayConfig};
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9
10/// Overflow policy for bounded pending encrypted message queues.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum OverflowPolicy {
14    /// Evict the oldest queued message to make room for the new message.
15    #[default]
16    DropOldest,
17    /// Drop the newly received message when capacity is reached.
18    DropNewest,
19}
20
21/// Configuration for inbound encrypted messages received before session readiness.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PendingQueueConfig {
24    /// Maximum number of pending encrypted messages per peer.
25    pub max_pending_per_peer: usize,
26    /// Maximum number of pending encrypted messages across all peers.
27    pub max_pending_global: usize,
28    /// Maximum total payload bytes (content plus binary content) queued per
29    /// peer.
30    ///
31    /// Encrypted media chunks are queued whole while the sender's session is
32    /// not yet ready, so the count limits alone would admit up to
33    /// `max_pending_per_peer` × chunk-size bytes of ciphertext. The default
34    /// (4 MB) covers the largest legitimate pre-session backlog: 2 concurrent
35    /// transfers × 8 in-flight chunks × 256 KB internet chunks.
36    #[serde(default = "default_max_pending_bytes_per_peer")]
37    pub max_pending_bytes_per_peer: usize,
38    /// Maximum total payload bytes queued across all peers. The default
39    /// (32 MB) bounds worst-case queue memory on mobile devices regardless of
40    /// how many peers are mid-establishment.
41    #[serde(default = "default_max_pending_bytes_global")]
42    pub max_pending_bytes_global: usize,
43    /// Time-to-live for pending encrypted messages in milliseconds.
44    pub pending_ttl_ms: u64,
45    /// Overflow policy when queue limits are reached.
46    pub overflow_policy: OverflowPolicy,
47}
48
49/// Default TTL for the pending-decryption queue (24 hours, in ms).
50///
51/// Under the deferred-ACK model an undecryptable message is no longer ACKed on
52/// receipt, so this queue is the primary recovery window before the session
53/// confirms — a 2-minute window was too short for a peer whose Welcome is slow
54/// to arrive/adopt, and 30 minutes was too short once the queue became
55/// durable: with a relay that pushes ciphertext without store-and-forward
56/// there is no second copy, so a frame that arrives while the recipient's
57/// session is still being set up has to outlive the handshake, which for a
58/// phone that is opened once a day means a day. Memory stays bounded by the
59/// per-peer/global byte caps plus the `DropOldest` overflow policy; a longer
60/// TTL only lets entries linger within those caps, it does not raise the
61/// ceiling. The TTL is measured on an `Instant` and so restarts with the
62/// process; the persisted copy is bounded separately by
63/// `PENDING_DECRYPT_PERSISTED_MAX_AGE_MS` (7 days).
64///
65/// The UniFFI `PendingQueueConfig` default mirrors this value — reference this
66/// constant there rather than re-hardcoding it.
67pub const DEFAULT_PENDING_TTL_MS: u64 = 86_400_000;
68
69fn default_max_pending_bytes_per_peer() -> usize {
70    4 * 1024 * 1024
71}
72
73fn default_max_pending_bytes_global() -> usize {
74    32 * 1024 * 1024
75}
76
77impl Default for PendingQueueConfig {
78    fn default() -> Self {
79        Self {
80            max_pending_per_peer: 64,
81            max_pending_global: 4096,
82            max_pending_bytes_per_peer: default_max_pending_bytes_per_peer(),
83            max_pending_bytes_global: default_max_pending_bytes_global(),
84            // 24 hours; see DEFAULT_PENDING_TTL_MS for the deferred-ACK
85            // rationale and the FFI-mirror contract.
86            pending_ttl_ms: DEFAULT_PENDING_TTL_MS,
87            overflow_policy: OverflowPolicy::DropOldest,
88        }
89    }
90}
91
92/// Encryption configuration for automatic MLS handling.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct EncryptionConfig {
95    /// Whether automatic encryption is enabled.
96    /// When enabled, messages are automatically encrypted/decrypted using MLS.
97    pub enabled: bool,
98
99    /// Auto-exchange key packages on peer discovery.
100    /// When enabled, key packages are automatically sent when neighbors are discovered.
101    pub auto_key_exchange: bool,
102
103    /// Store pending messages when no session exists.
104    /// Messages will be sent automatically after the session is established.
105    pub store_pending: bool,
106
107    /// Require encryption for messages (default: `true`).
108    ///
109    /// When enabled, sends fail closed if encryption cannot be applied:
110    /// with MLS uninitialized every send returns [`crate::Error::EncryptFailed`],
111    /// and with no confirmed session messages are queued (when
112    /// `store_pending` is set) or fail with
113    /// [`crate::Error::SessionNotReady`] instead of ever leaving as
114    /// plaintext. Inbound plaintext content — text messages and legacy
115    /// media alike — is rejected without being surfaced to the app
116    /// (plaintext carries no sender authentication, so anyone could
117    /// inject it under a contact's name); each rejection emits a
118    /// [`crate::events::SecurityWarningCode::PlaintextReceiveRejected`]
119    /// warning, once per peer. Even under the opt-out, inbound plaintext
120    /// from a peer *known to run MLS* is rejected as a downgrade/forgery
121    /// attempt — see `OfflineProtocol::encryption_capable_peers`. A
122    /// confirmed session is not required, and deliberately so: an honest
123    /// peer queues rather than downgrading while a session is pending, so
124    /// cleartext from a peer we know speaks MLS is never legitimate.
125    /// Peers that have shown no MLS signal stay readable, which is what
126    /// makes this opt-out usable for legacy interop.
127    ///
128    /// Disable only for deployments that deliberately send cleartext
129    /// (e.g. open broadcast/mesh bootstrap without provisioned MLS
130    /// storage) — set this to `false` explicitly, or use
131    /// [`EncryptionConfig::disabled`] to turn encryption off entirely.
132    /// Every plaintext send then emits a
133    /// [`crate::events::SecurityWarningCode::PlaintextSend`] warning
134    /// (once per peer). Internal control messages (key exchange, ACKs,
135    /// service discovery) are exempt and unaffected.
136    pub require_encryption: bool,
137
138    /// Bounds and eviction policy for pending inbound encrypted messages.
139    pub pending_queue: PendingQueueConfig,
140
141    /// Whether to negotiate and emit the compact MLS envelope for encrypted
142    /// messages (base64 of the binary `EncryptedMessage` form instead of JSON
143    /// with an integer-array ciphertext — roughly 2.7x smaller).
144    ///
145    /// Defaults to `true`. It only takes effect toward recipients that
146    /// advertise support in their key package (`env_versions`), so a mixed
147    /// fleet automatically stays on the JSON envelope. Parsing of inbound
148    /// compact envelopes is always on, independent of this flag; this gates
149    /// only advertising and emitting. The end-to-end sibling of
150    /// [`TransportConfig::binary_wire_enabled`], with an independent kill
151    /// switch because the two degrade separately.
152    pub compact_envelope_enabled: bool,
153
154    /// Whether to negotiate and seal the rich `__RICH_V1__` payload for
155    /// encrypted messages: quoted-reply context, rich media metadata, and
156    /// forward attribution carried *inside* the MLS ciphertext instead of on
157    /// the relay-visible outer message.
158    ///
159    /// Defaults to `true`. It only takes effect toward recipients that
160    /// advertise support in their key package (`rich_versions`); toward
161    /// everyone else the rich extras are silently dropped — never sent
162    /// cleartext. Parsing of inbound sealed payloads is always on,
163    /// independent of this flag; this gates only advertising and sealing.
164    /// Independent kill switch from
165    /// [`EncryptionConfig::compact_envelope_enabled`] — the two degrade
166    /// separately.
167    pub rich_payload_enabled: bool,
168
169    /// Whether to recover an inbound encrypted message that failed to decrypt,
170    /// rather than silently dropping it while telling the sender it was
171    /// delivered.
172    ///
173    /// Defaults to `true`. When enabled, an inbound encrypted DM (or media
174    /// chunk) that fails to decrypt is **not delivery-ACKed** and its id is not
175    /// left dedup-marked, so the sender keeps retrying instead of marking it
176    /// delivered — and each resend of a DM is re-sealed against the peer's
177    /// current session, so a resend can actually land. A message that stays
178    /// undeliverable settles as an honest `MessageFailed` once the sender's
179    /// retry budget lapses. When disabled, every decrypt failure falls back to
180    /// the legacy drop-and-ACK behavior.
181    ///
182    /// **Only an epoch mismatch additionally triggers a re-key.** A decrypt
183    /// failure *specifically* because the two sides disagree on the MLS epoch
184    /// (e.g. after a fork) also starts a rate-limited `session_reset`
185    /// key-package exchange to rebuild the channel. Failures that are *not* an
186    /// epoch mismatch — an AEAD / authentication failure, a discarded past
187    /// ratchet generation, a malformed frame — withhold the ACK but never
188    /// re-key: widening the re-key trigger to cover them would turn every
189    /// malformed frame into a teardown, which is an unbounded churn vector.
190    /// That separation is why the two classifications stay distinct.
191    ///
192    /// One consequence worth planning for: `MessageDecryptionFailed` is
193    /// **advisory, not terminal**, and fires once per failed *attempt* rather
194    /// than once per message. Treat it as "this attempt did not decrypt", and
195    /// read `MessageFailed` (or `FileReceiveFailed` for media) as the terminal
196    /// signal.
197    ///
198    /// **The re-key trigger is unauthenticated, by construction — do not read
199    /// this switch as "only genuine peers can cause a re-key".** An MLS epoch is
200    /// checked during *framing* validation, which happens before any AEAD,
201    /// sender-data, or signature check, and a 1:1 session slot id is derivable
202    /// from two public user ids. A hand-built frame carrying a wrong epoch
203    /// therefore reaches the recoverable classification with its sender still
204    /// entirely unverified: any party who can inject a frame can drive a re-key,
205    /// with no key material, no captured ciphertext and no session
206    /// (`test_forged_frame_reaches_session_desync_without_any_key_material`
207    /// builds one from scratch). This is inherent to MLS framing, not a defect,
208    /// and cannot be authenticated away — the credential a sender check would
209    /// compare against only exists once decrypt *succeeds*.
210    ///
211    /// What makes that safe is that acting on the trigger is **harmless**, not
212    /// that it is trusted: the re-key is confined to the claimed sender's own
213    /// session slot (so one derivable id cannot be aimed at other peers), is
214    /// rate-limited to one per peer per 30 s, discards no queued message, and
215    /// emits a `SESSION_REKEY_TRIGGERED` security warning so a sustained rate —
216    /// injection rather than a real fork — is visible to the app. The residual
217    /// is bounded re-key churn on a single pair: delivery delayed, never lost.
218    /// `OfflineProtocol::schedule_session_rekey` carries the full analysis.
219    ///
220    /// **The split-session heal is not governed by this switch.** Any ordinary
221    /// decrypt failure on a 1:1 session asks the peer to corroborate it, and
222    /// only a signed answer to that side's own live challenge, showing the two
223    /// hold different groups, makes the greater side give up its group (ADR
224    /// 0033). That trigger is authenticated, unlike the epoch re-key above, so
225    /// turning recovery off does not turn it off.
226    pub crypto_recovery_enabled: bool,
227
228    /// Whether service request and response bodies (`__SVC_REQ__`,
229    /// `__SVC_RESP__`) are sealed inside the recipient's 1:1 MLS session
230    /// instead of crossing every hop as signed plaintext.
231    ///
232    /// Defaults to `true`. It takes effect only toward a peer that advertised
233    /// `svc_versions` entry 1 in a signed key package (or proved it by sending
234    /// a sealed service frame) and only once the session with that peer is
235    /// confirmed. Toward everyone else, and before the session confirms, the
236    /// body still leaves as signed plaintext, because that form is the floor
237    /// every older peer depends on: the first request to a provider this
238    /// device has never met is readable by the hops that carry it. Discovery
239    /// (`__SVC_DISC_Q__` / `__SVC_DISC_R__`) is never sealed, since every hop
240    /// must read it to match and forward.
241    ///
242    /// Gates advertising, recording and emitting. Routing an inbound sealed
243    /// service frame is unconditional, so switching this off never makes a
244    /// peer's sealed request surface as chat text.
245    #[serde(default = "default_encrypt_service_messages")]
246    pub encrypt_service_messages: bool,
247}
248
249/// The default for [`EncryptionConfig::encrypt_service_messages`], by name so
250/// a config serialized before the field existed still parses, and parses on.
251fn default_encrypt_service_messages() -> bool {
252    true
253}
254
255impl Default for EncryptionConfig {
256    fn default() -> Self {
257        Self {
258            enabled: true,
259            auto_key_exchange: true,
260            store_pending: true,
261            // Fail closed by default (SEC-M3): a stock-config node must never
262            // silently degrade to plaintext because MLS was left uninitialized.
263            require_encryption: true,
264            pending_queue: PendingQueueConfig::default(),
265            compact_envelope_enabled: true,
266            rich_payload_enabled: true,
267            crypto_recovery_enabled: true,
268            encrypt_service_messages: true,
269        }
270    }
271}
272
273impl EncryptionConfig {
274    /// Creates a new encryption config with encryption disabled.
275    pub fn disabled() -> Self {
276        Self {
277            enabled: false,
278            auto_key_exchange: false,
279            store_pending: false,
280            require_encryption: false,
281            pending_queue: PendingQueueConfig::default(),
282            compact_envelope_enabled: false,
283            rich_payload_enabled: false,
284            crypto_recovery_enabled: false,
285            encrypt_service_messages: false,
286        }
287    }
288}
289
290/// Security configuration for transport and control-message hardening.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct SecurityConfig {
293    /// When `true`, control messages with no transport-level peer identity
294    /// (`transport_peer_id` is `None`) are rejected. When `false` (default),
295    /// missing transport identity emits a `SecurityWarning` but allows the
296    /// message through (best-effort / fail-open).
297    ///
298    /// # Why the default (`false`) is not the weak setting it once was
299    ///
300    /// This flag used to carry real weight, because a forged `sender` was
301    /// otherwise cheap: identities were app-chosen strings and the only thing
302    /// standing behind one was a trust-on-first-use pin, so first contact with
303    /// any peer was open to a man-in-the-middle and transport identity was the
304    /// only independent corroboration available.
305    ///
306    /// Under self-certifying addressing it is not. Every security-gated control
307    /// frame must now carry an Ed25519 signature — unconditionally, no longer
308    /// gated on this flag — and the signing key must derive to the address in
309    /// `sender`. A forged `sender` therefore requires the private key behind
310    /// that address, on first contact as much as on the thousandth. Transport
311    /// identity adds no authenticity on top of that; it only cross-checks a
312    /// claim the signature already proved.
313    ///
314    /// The strict match on frames claiming direct origin (`hop_count == 0`) runs
315    /// regardless of this flag, as it always has, and is unaffected.
316    ///
317    /// # What enabling it still does, and what it costs
318    ///
319    /// Exactly one thing: control frames arriving with **no** transport identity
320    /// at all are rejected instead of falling through to the signature gate.
321    ///
322    /// That is a real cost with no matching benefit on most deployments. Nostr
323    /// frames carry no transport identity by construction — the Nostr pubkey is
324    /// an install signing key, not the protocol id, so attaching it would fail
325    /// the strict match (see `nostr_message_received_inner`) — and relay frames
326    /// that arrive without a named sender are in the same position. Turning this
327    /// on rejects **every** control message on those paths, including the
328    /// key-package exchange that cold contact depends on. Mesh-forwarded frames
329    /// re-created by intermediate nodes legitimately lack identity too.
330    ///
331    /// Set to `true` only on a deployment that runs neither Nostr nor
332    /// sender-less relay delivery, and wants frames without transport identity
333    /// refused as a matter of policy rather than of authentication.
334    pub require_transport_identity: bool,
335
336    /// Whether a control frame is refused for being stale, and whether a peer
337    /// that has proved it signs the freshness-bound payload is held to it.
338    ///
339    /// Defaults to `true`. With it on, a control frame signed under
340    /// `offline-ctrl-v2` is checked against
341    /// [`CTRL_FRESHNESS_PAST_MS`](offline_protocol_sealed::CTRL_FRESHNESS_PAST_MS)
342    /// and its future counterpart, a peer that once verifiably signed v2 may
343    /// not fall back to v1, and a `session_reset` is honoured only on a frame
344    /// whose freshness was actually checked. With it off, this node behaves
345    /// exactly as it did before any of that existed: signatures are still
346    /// verified, and no frame is refused for its age.
347    ///
348    /// # What it is for
349    ///
350    /// The timestamp is read against *this device's* clock, so the failure
351    /// this switch exists for is a clock, not a peer. A device that comes up
352    /// with an unset clock reads every honest peer's frame as being from
353    /// decades in the future and refuses all of them, which takes out the
354    /// whole control plane — key package exchange included — for as long as
355    /// the clock is wrong. That is a self-inflicted outage a shipped app
356    /// cannot wait for a new binary to fix, which is why the lever exists and
357    /// why it reaches the bindings rather than staying internal.
358    ///
359    /// # What turning it off costs
360    ///
361    /// A captured control frame verifies forever again, and a recorded
362    /// `session_reset` is once more a repeatable way to tear down a live
363    /// session (issue 403). It is a diagnosis and recovery tool, not a
364    /// configuration a deployment should settle on.
365    pub control_freshness_enforced: bool,
366}
367
368impl Default for SecurityConfig {
369    fn default() -> Self {
370        Self {
371            // Fail-open, and safe to: see the field's own documentation for
372            // why transport identity adds no authenticity over a signature
373            // that must derive to the sender's address.
374            require_transport_identity: false,
375            // Fail-closed, like `require_encryption`: a freshness check that
376            // has to be switched on is one that is off on every install that
377            // never heard about it.
378            control_freshness_enforced: true,
379        }
380    }
381}
382
383/// Transport-specific configuration.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct TransportConfig {
386    /// Whether BLE transport is enabled.
387    pub ble_enabled: bool,
388
389    /// Whether Wi-Fi Direct transport is enabled.
390    pub wifi_direct_enabled: bool,
391
392    /// Whether Internet transport is enabled.
393    pub internet_enabled: bool,
394
395    /// Whether Reticulum mesh transport is enabled.
396    /// Defaults to `false` because Reticulum requires external infrastructure
397    /// (a running Reticulum daemon or RNode hardware).
398    pub reticulum_enabled: bool,
399
400    /// Whether Nostr relay transport is enabled.
401    /// Defaults to `false` because Nostr requires relay configuration
402    /// and a secp256k1 keypair.
403    pub nostr_enabled: bool,
404
405    /// Whether outgoing Nostr frames are sealed into NIP-59 gift wraps.
406    ///
407    /// Defaults to `true`. With it off, the transport publishes the legacy
408    /// kind-4 event whose content is the whole protocol envelope in cleartext —
409    /// both usernames, the app id, the metadata map, the content type and a
410    /// millisecond timestamp, readable by every relay, permanently. Turn it off
411    /// only to interoperate with a relay that rejects kind 1059 outright.
412    ///
413    /// Send-side only, and never negotiated: inbound gift wraps are always
414    /// unsealed and inbound legacy frames always parsed, so whatever this is
415    /// set to, anything a peer sends us stays readable.
416    ///
417    /// It does still affect *outbound* reachability, in the one direction the
418    /// receive path cannot cover. A peer on a build that predates sealing
419    /// subscribes with `kinds: [4]` only, so it is never handed our kind-1059
420    /// events and has no NIP-44 layer to open one with — leaving it on makes
421    /// such a peer unreachable over Nostr (visibly: no ACK returns, so the
422    /// send fails through the retry ladder rather than vanishing). That is the
423    /// second reason to turn this off, alongside a relay that rejects
424    /// kind 1059.
425    pub nostr_sealing_enabled: bool,
426
427    /// Whether this install publishes MLS key packages to the relays and
428    /// resolves peers' published packages.
429    ///
430    /// Defaults to `true`, and takes effect only when `nostr_enabled` is on —
431    /// which is itself off by default, so nothing is published unless an app
432    /// deliberately turns Nostr on.
433    ///
434    /// Buys **cold first contact**: a peer known only by address becomes
435    /// reachable over Nostr with no prior key-package exchange over some other
436    /// transport, which is otherwise impossible. A send to, or a session
437    /// asked for with, a peer this device holds no key package for asks the
438    /// relays for one that peer published. It also bounds the sealing
439    /// downgrade that follows a peer-key eviction, since a forgotten key is
440    /// re-resolved on the next send instead of waiting for a fresh exchange.
441    ///
442    /// **What it costs is worth stating plainly.** Publication is the first
443    /// thing this SDK emits to a relay unprompted: a small set of addressable
444    /// records sits at this install's routing tag and is refreshed as slots are
445    /// consumed, whether or not any message is ever sent. Their *content* is
446    /// sealed — a scraper filtering on the kind alone reads nothing, which
447    /// matters because an MLS key package carries its owner's username in the
448    /// leaf credential and cannot be stripped of it — but the existence of a
449    /// record at a given tag, and the timing of its refreshes, are visible to
450    /// every relay published to. Turn this off to keep the transport silent
451    /// until it has traffic, at the price of cold contact.
452    pub nostr_cold_contact_enabled: bool,
453
454    /// Whether this install publishes a username discovery record and can
455    /// resolve usernames to the devices claiming them.
456    ///
457    /// Defaults to **`false`**, unlike `nostr_cold_contact_enabled`, and gates
458    /// publication and resolution together. It also *requires* cold contact:
459    /// a discovery record points at an address whose key packages are what a
460    /// resolver fetches next, so with cold contact off the claim resolves and
461    /// then dead-ends one hop later. The two are hard-coupled in the transport
462    /// rather than merely documented.
463    ///
464    /// Buys back **reach by username**, which the addressing migration removed:
465    /// a stranger who knows only a name can find the addresses claiming it and
466    /// open a session. The name is published as the app's configured `profile`,
467    /// normalized to NFC and lowercase.
468    ///
469    /// **Default-off is the deliberate choice, and the reason is disclosure.**
470    /// Publishing binds a human-readable name to an address in a public place.
471    /// That is materially more than the key-package record's "an install with
472    /// this tag exists": the mapping *is* the payload. The record is sealed to
473    /// a key derived from the username, so a relay scraping by kind alone reads
474    /// nothing, but anyone who guesses the name can compute the tag, confirm a
475    /// claim exists, and read it. That is the same class of exposure a public
476    /// user directory has, and it is a decision an app should make explicitly.
477    ///
478    /// **What the directory is not: authoritative.** Anyone may publish any
479    /// claim at any name. Every record is a claim that *some key* asserts a
480    /// name, and a resolution returns the whole set of claimants with their
481    /// verification status. An app that silently picks the first result has
482    /// converted a non-authoritative directory into an authoritative-looking
483    /// one; the user must confirm out of band. See
484    /// `docs/spec/username-discovery.md`.
485    pub nostr_username_discovery_enabled: bool,
486
487    /// Whether to negotiate and emit the compact binary wire codec.
488    ///
489    /// Defaults to `true`. It only takes effect between two peers that both
490    /// advertise support (via their signed key package), so a mixed fleet
491    /// automatically stays on JSON. Decoding of binary frames is always on,
492    /// independent of this flag; this gates only advertising and emitting.
493    pub binary_wire_enabled: bool,
494}
495
496impl Default for TransportConfig {
497    fn default() -> Self {
498        Self {
499            ble_enabled: true,
500            wifi_direct_enabled: true,
501            internet_enabled: true,
502            reticulum_enabled: false,
503            nostr_enabled: false,
504            nostr_sealing_enabled: true,
505            nostr_cold_contact_enabled: true,
506            nostr_username_discovery_enabled: false,
507            binary_wire_enabled: true,
508        }
509    }
510}
511
512/// Reliability configuration combining ACK, retry, and deduplication settings.
513#[derive(Debug, Clone, Default)]
514pub struct ReliabilityConfig {
515    /// ACK manager configuration.
516    pub ack: AckConfig,
517
518    /// Retry queue configuration.
519    pub retry: RetryConfig,
520
521    /// Deduplicator configuration.
522    pub dedup: DeduplicatorConfig,
523}
524
525/// Configuration for group messaging.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct GroupConfig {
528    /// Maximum number of members allowed in a single group.
529    ///
530    /// Per-member fan-out is O(N) when using mesh transports.
531    /// This cap prevents unbounded groups from overwhelming the network.
532    pub max_group_members: usize,
533
534    /// Whether to attempt relay server registration for groups.
535    ///
536    /// When `true` (default), groups are registered with the relay server
537    /// for optimized fan-out when Internet transport is available.
538    /// When `false`, groups always use per-member fan-out regardless of
539    /// transport.
540    ///
541    /// This gates *registration* only — the relay group registry is what
542    /// invite links resolve against, so it stays on independently of
543    /// [`Self::relay_broadcast_enabled`].
544    pub relay_enabled: bool,
545
546    /// Whether a relay-synced group may send via a single O(1) relay
547    /// broadcast instead of per-member fan-out.
548    ///
549    /// **Default `true`, and additionally gated on the relay's advertised
550    /// capabilities.** The flag alone does not select the broadcast: the
551    /// path is taken only when the connected relay also advertised the
552    /// `group_delivery_v3` capability in its `Authenticated` answer (v3 =
553    /// the v2 settled-report contract plus an address-aware group path —
554    /// members named in the identifier space the roster was registered in,
555    /// so the report is comparable against the MLS roster). Against
556    /// such a relay the broadcast keeps a delivery contract — the relay
557    /// answers every broadcast with a *settled* per-recipient delivery
558    /// report (`GroupMessageSent`) naming who took the message over a
559    /// socket and who took a device push, and the SDK re-sends a per-member
560    /// copy through the ordinary outbox/ACK/park ladder to every MLS roster
561    /// member the report does not account for. A lost report re-sends the
562    /// broadcast under the same logical message id (receiver and push dedup
563    /// absorb the duplicate fan-out) a bounded number of times, then
564    /// downgrades the message to full per-member fan-out. Against an older
565    /// relay the capability gate fails and every send takes per-member
566    /// fan-out — the fire-and-forget broadcast of the v1 relay (no presence
567    /// check, no push fallback, no persistence, "sent" answered before
568    /// delivery was known) is never taken, regardless of this flag.
569    ///
570    /// The one gap on the broadcast path: the tracker awaiting the report
571    /// lives in memory only, so a process death inside the report window
572    /// (up to `RELAY_BROADCAST_REPORT_TIMEOUT_SECS` per attempt) loses the
573    /// backstop with it — members the relay could not reach get no
574    /// per-member re-send, and nothing retries on restart. It needs both a
575    /// missed member and a kill in that window, and is strictly smaller than
576    /// the pre-report broadcast's exposure, but it is a real difference from
577    /// per-member fan-out, whose outbox entries persist for 7 days.
578    ///
579    /// Set `false` to force per-member fan-out even against a v2 relay —
580    /// the right choice for an app that must not lose a group message to a
581    /// mid-flight process kill.
582    /// Per-member fan-out (`__GRP_MLS_MSG__` as ordinary `SendMessage`
583    /// frames) inherits the full DM ladder end to end — outbox + ACK +
584    /// retry, relay write-ack + successor retry + offline push carrying the
585    /// ciphertext, park-on-unreachable with presence-driven flush, and
586    /// receiver-side deferred-ACK-after-decrypt — at the cost of O(N)
587    /// uplink frames per send.
588    ///
589    /// O(N) fan-out does **not** risk tripping the relay's rate limiter at
590    /// any group size. The platform bridge meters every relay-bound frame
591    /// through a client-side token bucket (`RelayRateLimiter`: 28 capacity,
592    /// 9/s refill) deliberately tighter than the relay's (30 burst, 10/s),
593    /// and a frame that cannot take a token is *deferred to a later poll
594    /// tick*, never dropped — so client spend stays at `28 + 9t` against a
595    /// server budget of `30 + 10t` and the fan-out self-paces below it
596    /// regardless of member count. Core enqueues all N frames at once; the
597    /// bridge drains that queue.
598    ///
599    /// What large groups actually cost on the per-member path is drain
600    /// latency. Frame N reaches the wire at roughly `(N - 28) / 9` seconds.
601    /// Since the ACK timer starts at local *enqueue* (`handle_send_success`)
602    /// and not at wire-confirm — `on_transport_send_confirmed` advances only
603    /// the welcome lifecycle — past roughly 118 members the tail of a single
604    /// fan-out exceeds the 10s ACK timeout and is retransmitted by the
605    /// ladder before it was ever written. That is wasted duplicate frames,
606    /// absorbed by receiver and push dedup via the stable outbox id, not
607    /// lost messages. Presence checks, typing, and receipts draw on the same
608    /// bucket and lower the threshold. This is the strongest reason to leave
609    /// the broadcast enabled for large groups.
610    pub relay_broadcast_enabled: bool,
611
612    /// Whether an incoming MLS membership commit is refused when the local
613    /// admin overlay does not authorize its committer.
614    ///
615    /// **Off by default, and turning it on is a decision about partition
616    /// risk, not a hardening toggle.** Refusing a commit means declining the
617    /// MLS merge: our epoch stays behind every member who did merge, and MLS
618    /// has no way back — the application has to re-invite us. Enforcement is
619    /// therefore fork-free only if every member reaches the *same* verdict,
620    /// and the admin overlay is replicated best-effort (role changes ride
621    /// unreconciled mesh notifications; a joiner gets a point-in-time
622    /// snapshot). Two members whose role maps merely disagree will partition
623    /// each other with no attacker involved.
624    ///
625    /// The check fails open on every *absent* input — no metadata, no admin
626    /// role stored, an unreadable roster — so the common lag case stays
627    /// harmless. It cannot detect *divergent* views, which is the residual
628    /// risk that keeps this opt-in. Enable it only for a closed deployment
629    /// that controls role distribution.
630    ///
631    /// With it off (the default), unauthorized membership changes are still
632    /// applied and reported: `group_unauthorized_membership_change` fires and
633    /// the roster events carry `authorized: false`. That reporting is
634    /// unaffected by this flag; the flag only decides whether the commit is
635    /// *also* refused.
636    ///
637    /// Rejection is receiver-local. The sender's message is still ACKed, so
638    /// a committer gets no signal that anyone refused, and members with the
639    /// flag off will apply a commit that members with it on rejected — do not
640    /// enable it on part of a fleet.
641    #[serde(default)]
642    pub enforce_admin_commits: bool,
643}
644
645impl Default for GroupConfig {
646    fn default() -> Self {
647        Self {
648            max_group_members: 256,
649            relay_enabled: true,
650            relay_broadcast_enabled: true,
651            enforce_admin_commits: false,
652        }
653    }
654}
655
656/// Configuration for the replicated-document data layer.
657///
658/// [`Self::enabled`] governs the whole layer: with it off no documents are
659/// opened, nothing is persisted, and no capability is advertised to peers.
660#[derive(Clone, Serialize, Deserialize)]
661pub struct DataConfig {
662    /// Whether the data layer accepts work at all.
663    ///
664    /// **Defaults to `true`.** It defaulted to `false` while the layer could
665    /// store documents but not replicate them: advertising a capability with
666    /// no sync behind it invites peers to expect a sync that never comes.
667    /// Both halves land in the same release, so no application ever ran
668    /// against the old default, and the switch is on.
669    ///
670    /// What being on costs an application that ignores documents entirely:
671    /// nothing at rest and nothing on the wire. The layer allocates when a
672    /// store is opened, and a capability is advertised in a key package that
673    /// is sent anyway. What it buys is that an application that *does* open a
674    /// store gets replication without discovering a flag.
675    #[serde(default = "default_data_enabled")]
676    pub enabled: bool,
677
678    /// Where documents are stored, when the application wants them somewhere
679    /// other than the store protocol state already uses.
680    ///
681    /// `None` — the default — means documents live in the same backend as the
682    /// rest of protocol state, which is what makes the layer work with zero
683    /// storage setup: every binding already ships a default provider.
684    ///
685    /// Setting it points documents at SQLite, RocksDB, files, a corporate
686    /// store, or anything else implementing the same trait, while protocol
687    /// secrets stay exactly where they are. The two concerns swap
688    /// independently and the swap is a runtime choice: no rebuild, no cargo
689    /// feature, and no change to any data API. Anything that makes this
690    /// harder than assigning this field is a design regression.
691    ///
692    /// Sealing sits above this seam. An adapter is handed sealed bytes and
693    /// never sees document content, so a custom backend cannot weaken the
694    /// at-rest posture even by accident.
695    ///
696    /// # Logout
697    ///
698    /// `wipePersistedState` clears the account directory of the *default*
699    /// provider. A custom backend is somewhere the bindings cannot know
700    /// about, so an application that sets this must call the data layer's
701    /// own wipe on logout, or documents outlive the account that made them.
702    #[serde(skip)]
703    pub storage: Option<Arc<dyn crate::protocol_state_storage::ProtocolStateStorage>>,
704}
705
706/// The default for [`DataConfig::enabled`], needed by name because `serde`
707/// cannot ask a field's type for anything other than its own default.
708fn default_data_enabled() -> bool {
709    true
710}
711
712impl Default for DataConfig {
713    fn default() -> Self {
714        Self {
715            enabled: default_data_enabled(),
716            storage: None,
717        }
718    }
719}
720
721impl std::fmt::Debug for DataConfig {
722    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
723        f.debug_struct("DataConfig")
724            .field("enabled", &self.enabled)
725            .field("storage", &self.storage.as_ref().map(|_| "<custom>"))
726            .finish()
727    }
728}
729
730/// How long a custodian holds a deposited frame by default: six hours.
731///
732/// Hours, not days, because a custodian holds ciphertext it cannot re-seal
733/// (`docs/spec/custody.md`, invariant three): the depositor survives a
734/// session re-key by re-sealing from retained plaintext, and the custodian's
735/// copy has a validity horizon it cannot observe. Strictly under the seven-day
736/// outbox lifetime by a wide margin, so the default configuration validates.
737pub const DEFAULT_CUSTODY_HOLD_MS: u64 = 6 * 60 * 60 * 1000;
738
739/// Held frames one depositor may have in custody at once, by default.
740pub const DEFAULT_CUSTODY_MAX_ENTRIES_PER_DEPOSITOR: usize = 64;
741
742/// Bytes one depositor may have in custody at once, by default: 2 MiB.
743pub const DEFAULT_CUSTODY_MAX_BYTES_PER_DEPOSITOR: usize = 2 * 1024 * 1024;
744
745/// Held frames across every depositor, by default.
746pub const DEFAULT_CUSTODY_MAX_ENTRIES: usize = 512;
747
748/// Bytes across every depositor, by default: 16 MiB.
749pub const DEFAULT_CUSTODY_MAX_BYTES: usize = 16 * 1024 * 1024;
750
751/// The smallest per-depositor byte budget that admits one replication frame at
752/// its ceiling: 64 KiB.
753///
754/// A Class A frame carries at most 32 KiB of blob
755/// (`data_sync::MAX_SYNC_BLOB_BYTES`), which base64 grows by a third, which
756/// the MLS envelope then encodes again, so a sealed frame at the ceiling is
757/// around 58 KiB of content with the outer message's addresses and metadata
758/// on top. A byte cap below this admits no such frame and refuses every
759/// deposit as `depositor_full`, which is the silent dial the `enabled` switch
760/// exists to replace.
761pub const CUSTODY_MIN_BYTES_PER_DEPOSITOR: usize = 64 * 1024;
762
763/// Custody: holding a neighbour's replication frame for hours instead of the
764/// seconds a forwarder gives it (`docs/spec/custody.md`).
765///
766/// Off by default, and every dial here is the custodian's. A device that
767/// never enables it behaves exactly as before, and a device that enables it
768/// takes on other people's ciphertext under the quotas below. The hold is
769/// validated strictly shorter than `reliability.retry.outbox_max_lifetime_ms`,
770/// because a custodian that outlived the depositor's outbox would deliver
771/// frames whose sender already reported them failed.
772#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
773pub struct CustodyConfig {
774    /// Whether this device accepts deposits. The off switch: refusing to hold
775    /// other people's traffic is this dial's job, never a zeroed budget's.
776    pub enabled: bool,
777    /// How long an accepted frame is held, in milliseconds, judged in wall
778    /// time from each record's acceptance timestamp against the value in
779    /// force at the sweep. Lowering it expires records already held.
780    pub hold_ms: u64,
781    /// Held frames one depositor with an established session may have at once.
782    pub max_entries_per_depositor: usize,
783    /// Bytes one depositor with an established session may have at once.
784    pub max_bytes_per_depositor: usize,
785    /// Held frames across every depositor.
786    pub max_entries: usize,
787    /// Bytes across every depositor.
788    pub max_bytes: usize,
789    /// Held frames one proven peer *without* an established session may have
790    /// at once. Zero, the default, means deposits are accepted only from peers
791    /// with a session, which costs a key package exchange and is what makes
792    /// the session tier resistant to an attacker who mints addresses for free.
793    pub stranger_max_entries: usize,
794    /// Bytes one proven peer without an established session may have at once.
795    pub stranger_max_bytes: usize,
796    /// What happens when a budget is full: evict the oldest held frame to
797    /// admit the new one, or refuse the new one.
798    pub overflow_policy: OverflowPolicy,
799}
800
801impl Default for CustodyConfig {
802    fn default() -> Self {
803        Self {
804            enabled: false,
805            hold_ms: DEFAULT_CUSTODY_HOLD_MS,
806            max_entries_per_depositor: DEFAULT_CUSTODY_MAX_ENTRIES_PER_DEPOSITOR,
807            max_bytes_per_depositor: DEFAULT_CUSTODY_MAX_BYTES_PER_DEPOSITOR,
808            max_entries: DEFAULT_CUSTODY_MAX_ENTRIES,
809            max_bytes: DEFAULT_CUSTODY_MAX_BYTES,
810            stranger_max_entries: 0,
811            stranger_max_bytes: 0,
812            overflow_policy: OverflowPolicy::DropOldest,
813        }
814    }
815}
816
817/// Main configuration for the Offline Protocol.
818#[derive(Debug, Clone)]
819pub struct ProtocolConfig {
820    /// Application identifier (required).
821    ///
822    /// Two jobs. It is one of the two inputs to the storage namespace, with
823    /// [`Self::profile`], so changing it selects fresh storage and a fresh
824    /// identity. And it is the id stamped on every frame this instance
825    /// originates: messages, media chunks, acknowledgements and control
826    /// traffic. A rich send may name another id for its own frame
827    /// ([`crate::SendMessageOptions::app_id`]), which is how one instance
828    /// serves several local applications behind one identity; the
829    /// acknowledgements and control frames the engine sends always carry
830    /// this one. The bindings also derive the BLE app tag from it.
831    ///
832    /// It is cleartext on the wire and covered by no signature, so every
833    /// carrier and hop can read it and rewrite it (threat model R17). It
834    /// names an application, so it is not secret, but treat it as routing
835    /// metadata only: the receive path delivers a message whatever id it
836    /// carries.
837    pub app_id: String,
838
839    /// Local profile selector (required).
840    ///
841    /// Selects which stored identity this instance runs as — one namespace per
842    /// `(app_id, profile)` pair — so an app hosting several accounts gives each
843    /// its own value, and an app hosting one can pass a constant like
844    /// `"default"`.
845    ///
846    /// It is **not** the device's identity. The wire identity is the
847    /// self-certifying address derived from the identity key held in this
848    /// profile's storage: it is minted on first start, reported by
849    /// [`crate::OfflineProtocol::local_address`], and it is what gets stamped
850    /// as `Message.sender`, what peers see as `NeighborDiscovered.peer_id`,
851    /// and what others use as `recipient`. An app cannot choose it, because
852    /// choosing it would mean claiming a key one does not hold.
853    ///
854    /// Under any configuration that reaches the wire the profile stays local.
855    /// The one exception is the explicit
856    /// [`EncryptionConfig::require_encryption`]` = false` opt-out, which lets a
857    /// plaintext send go out with MLS never initialized — no address has been
858    /// derived at that point, so the frame carries the profile as its sender
859    /// and the send emits
860    /// [`crate::events::SecurityWarningCode::PlaintextSend`]. The default
861    /// (`require_encryption = true`) fails such a send closed instead. Treat
862    /// the profile as private if that opt-out is in use.
863    pub profile: String,
864
865    /// Transport configuration.
866    pub transport: TransportConfig,
867
868    /// DORS (Dynamic Offline Relay Switch) configuration.
869    pub dors: DorsConfig,
870
871    /// Relay management configuration: whether this device carries traffic
872    /// for other people at all, and the battery floors under which it stops.
873    pub relay: RelayConfig,
874
875    /// How this device carries traffic for other people: how far frames
876    /// travel, how many neighbors each is handed to, and the ceilings that
877    /// keep forwarding from crowding the radio out.
878    ///
879    /// Whether to forward is [`Self::relay`]'s decision; this is the shape of
880    /// it once the answer is yes.
881    pub mesh_relay: MeshRelayConfig,
882
883    /// Reliability layer configuration.
884    pub reliability: ReliabilityConfig,
885
886    /// Encryption configuration for automatic MLS handling.
887    pub encryption: EncryptionConfig,
888
889    /// Initial TTL (Time-To-Live) for messages.
890    pub initial_ttl: u8,
891
892    /// Mesh group messaging configuration.
893    pub group: GroupConfig,
894
895    /// Security configuration for transport and control-message hardening.
896    pub security: SecurityConfig,
897
898    /// Replicated-document data layer configuration.
899    pub data: DataConfig,
900
901    /// Custody: holding a neighbour's replication frames for hours. Off by
902    /// default; see [`CustodyConfig`].
903    pub custody: CustodyConfig,
904}
905
906impl ProtocolConfig {
907    /// Creates a new protocol configuration.
908    ///
909    /// # Arguments
910    ///
911    /// * `app_id` - Application identifier
912    /// * `profile` - Local profile selector (storage namespace, never on the wire)
913    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
914        Self {
915            app_id: app_id.into(),
916            profile: profile.into(),
917            transport: TransportConfig::default(),
918            dors: DorsConfig::default(),
919            relay: RelayConfig::default(),
920            mesh_relay: MeshRelayConfig::default(),
921            reliability: ReliabilityConfig::default(),
922            encryption: EncryptionConfig::default(),
923            initial_ttl: DEFAULT_INITIAL_TTL,
924            group: GroupConfig::default(),
925            security: SecurityConfig::default(),
926            data: DataConfig::default(),
927            custody: CustodyConfig::default(),
928        }
929    }
930
931    /// Creates a builder for more granular configuration.
932    pub fn builder(app_id: impl Into<String>, profile: impl Into<String>) -> ProtocolConfigBuilder {
933        ProtocolConfigBuilder::new(app_id, profile)
934    }
935
936    /// Validates the configuration.
937    ///
938    /// # Returns
939    ///
940    /// Returns `Ok(())` if valid, `Err` with a description of the problem if invalid.
941    pub fn validate(&self) -> crate::Result<()> {
942        // Both become storage keys — `app_id` and `profile` are the two inputs
943        // to the account namespace — so hold them to the same length cap and
944        // charset ban as every other identifier rather than only rejecting the
945        // empty string. Going through the core constructors keeps one policy
946        // instead of a second copy that can drift. Catching this here turns a
947        // later failure deep inside a storage backend into one clear
948        // configuration error.
949        offline_protocol_core::AppId::new(self.app_id.as_str())
950            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
951
952        offline_protocol_core::UserId::new(self.profile.as_str())
953            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
954
955        if self.initial_ttl == 0 {
956            return Err(crate::Error::InvalidConfiguration(
957                "initial_ttl must be greater than 0".to_string(),
958            ));
959        }
960
961        if !self.transport.ble_enabled
962            && !self.transport.wifi_direct_enabled
963            && !self.transport.internet_enabled
964            && !self.transport.reticulum_enabled
965            && !self.transport.nostr_enabled
966        {
967            return Err(crate::Error::InvalidConfiguration(
968                "At least one transport must be enabled".to_string(),
969            ));
970        }
971
972        if self.reliability.ack.default_timeout_ms == 0 {
973            return Err(crate::Error::InvalidConfiguration(
974                "ack.default_timeout_ms must be greater than 0".to_string(),
975            ));
976        }
977
978        if self.reliability.ack.max_pending_acks == 0 {
979            return Err(crate::Error::InvalidConfiguration(
980                "ack.max_pending_acks must be greater than 0".to_string(),
981            ));
982        }
983
984        if self.reliability.retry.initial_delay_ms == 0 {
985            return Err(crate::Error::InvalidConfiguration(
986                "retry.initial_delay_ms must be greater than 0".to_string(),
987            ));
988        }
989
990        if self.reliability.retry.max_delay_ms == 0 {
991            return Err(crate::Error::InvalidConfiguration(
992                "retry.max_delay_ms must be greater than 0".to_string(),
993            ));
994        }
995
996        if self.reliability.retry.initial_delay_ms > self.reliability.retry.max_delay_ms {
997            return Err(crate::Error::InvalidConfiguration(
998                "retry.initial_delay_ms must be <= retry.max_delay_ms".to_string(),
999            ));
1000        }
1001
1002        if self.reliability.retry.outbox_max_lifetime_ms == 0
1003            || self.reliability.retry.outbox_max_lifetime_ms > i64::MAX as u64
1004        {
1005            return Err(crate::Error::InvalidConfiguration(
1006                "retry.outbox_max_lifetime_ms must be in 1..=i64::MAX".to_string(),
1007            ));
1008        }
1009
1010        if self.reliability.retry.pending_message_max_lifetime_ms == 0 {
1011            return Err(crate::Error::InvalidConfiguration(
1012                "retry.pending_message_max_lifetime_ms must be greater than 0".to_string(),
1013            ));
1014        }
1015
1016        if self.reliability.retry.pending_message_max_lifetime_ms > i64::MAX as u64 {
1017            return Err(crate::Error::InvalidConfiguration(
1018                "retry.pending_message_max_lifetime_ms must be <= i64::MAX".to_string(),
1019            ));
1020        }
1021
1022        if !self.reliability.retry.backoff_multiplier.is_finite()
1023            || self.reliability.retry.backoff_multiplier < 1.0
1024        {
1025            return Err(crate::Error::InvalidConfiguration(
1026                "retry.backoff_multiplier must be finite and >= 1.0".to_string(),
1027            ));
1028        }
1029
1030        // Checked outside the Bloom guard below, and that placement is the
1031        // whole point: these two are the *only* dedup fields the UniFFI
1032        // `DedupConfig` carries, so every constraint hidden behind
1033        // `use_bloom_filter` is unreachable from a binding caller. A zero here
1034        // is not a cosmetic problem — `Deduplicator` fails safe on the Bloom
1035        // parameters, but nothing fails safe on these. At
1036        // `max_tracked_messages == 0` the HashMap branch of `mark_seen` evicts
1037        // on every insert, so the map holds one id and duplicate suppression is
1038        // effectively off; `retention_time_secs == 0` expires every entry
1039        // immediately for the same net result.
1040        //
1041        // What this rejects is the *degenerate* value, and that is the whole of
1042        // the claim: a floor of 1 is not a floor on how well duplicates are
1043        // suppressed. `max_tracked_messages == 1` behaves indistinguishably
1044        // from `0`, and a two-second retention on a link whose retry backoff is
1045        // longer than that suppresses nothing either. Choosing a window wide
1046        // enough for the deployment is the application's call — the SDK refuses
1047        // only the values that cannot be a choice, because nothing downstream
1048        // spells them as a configuration error and the failure they produce
1049        // (silently re-delivered messages) does not look like a config bug.
1050        // Raising either floor to a policy minimum is a wider behavioural break
1051        // and would need its own decision.
1052        if self.reliability.dedup.max_tracked_messages == 0 {
1053            return Err(crate::Error::InvalidConfiguration(
1054                "reliability.dedup.max_tracked_messages must be greater than 0".to_string(),
1055            ));
1056        }
1057
1058        if self.reliability.dedup.retention_time_secs == 0 {
1059            return Err(crate::Error::InvalidConfiguration(
1060                "reliability.dedup.retention_time_secs must be greater than 0".to_string(),
1061            ));
1062        }
1063
1064        if self.reliability.dedup.use_bloom_filter {
1065            if self.reliability.dedup.bloom_filter_bits == 0 {
1066                return Err(crate::Error::InvalidConfiguration(
1067                    "reliability.dedup.bloom_filter_bits must be greater than 0".to_string(),
1068                ));
1069            }
1070
1071            if self.reliability.dedup.bloom_hash_count == 0 {
1072                return Err(crate::Error::InvalidConfiguration(
1073                    "reliability.dedup.bloom_hash_count must be greater than 0".to_string(),
1074                ));
1075            }
1076
1077            if self.reliability.dedup.bloom_filter_count == 0 {
1078                return Err(crate::Error::InvalidConfiguration(
1079                    "reliability.dedup.bloom_filter_count must be greater than 0".to_string(),
1080                ));
1081            }
1082
1083            if self.reliability.dedup.bloom_rotation_secs == 0 {
1084                return Err(crate::Error::InvalidConfiguration(
1085                    "reliability.dedup.bloom_rotation_secs must be greater than 0".to_string(),
1086                ));
1087            }
1088        }
1089
1090        if self.encryption.require_encryption && !self.encryption.enabled {
1091            return Err(crate::Error::InvalidConfiguration(
1092                "encryption.require_encryption requires encryption.enabled=true".to_string(),
1093            ));
1094        }
1095
1096        if self.encryption.pending_queue.max_pending_per_peer == 0 {
1097            return Err(crate::Error::InvalidConfiguration(
1098                "encryption.pending_queue.max_pending_per_peer must be greater than 0".to_string(),
1099            ));
1100        }
1101
1102        if self.encryption.pending_queue.max_pending_global == 0 {
1103            return Err(crate::Error::InvalidConfiguration(
1104                "encryption.pending_queue.max_pending_global must be greater than 0".to_string(),
1105            ));
1106        }
1107
1108        if self.encryption.pending_queue.max_pending_global
1109            < self.encryption.pending_queue.max_pending_per_peer
1110        {
1111            return Err(crate::Error::InvalidConfiguration(
1112                "encryption.pending_queue.max_pending_global must be >= max_pending_per_peer"
1113                    .to_string(),
1114            ));
1115        }
1116
1117        if self.encryption.pending_queue.pending_ttl_ms == 0 {
1118            return Err(crate::Error::InvalidConfiguration(
1119                "encryption.pending_queue.pending_ttl_ms must be greater than 0".to_string(),
1120            ));
1121        }
1122
1123        if self.group.max_group_members == 0 {
1124            return Err(crate::Error::InvalidConfiguration(
1125                "group.max_group_members must be greater than 0".to_string(),
1126            ));
1127        }
1128
1129        // A fan-out of zero is not a cheaper forward, it is a silent drop: the
1130        // frame has already been admitted and recorded as seen, so no copy goes
1131        // out and this node's suppression entry stops it arriving again by
1132        // another path. Same argument as `bias_min_scale` below — declining to
1133        // carry other people's traffic is `relay.allow_relay`'s job, and it is
1134        // visible. The forwarding paths clamp with `.max(1)`; rejecting zero
1135        // here keeps that clamp an unreachable defensive floor instead of a
1136        // silent rewrite of what zero means.
1137        if self.mesh_relay.fanout == 0 {
1138            return Err(crate::Error::InvalidConfiguration(
1139                "mesh_relay.fanout must be greater than 0".to_string(),
1140            ));
1141        }
1142
1143        // The same argument as fan-out, for every other dial that can reach
1144        // zero. Each of these reads like a conservative setting and is in fact
1145        // an off switch that reports nothing: the failure is total, silent, and
1146        // indistinguishable from a quiet neighborhood, which is what makes
1147        // rejecting it at construction worth more than any runtime clamp.
1148        //
1149        // A hop ceiling of zero clamps every arriving budget to nothing, so
1150        // `prepare_hop` refuses the frame before it is ever queued. Both
1151        // ceilings are checked: the dense one applies only in a crowded room,
1152        // so a zero there fails exactly where the mesh is most needed and
1153        // nowhere else — the hardest version of this bug to reproduce.
1154        if self.mesh_relay.max_ttl == 0 {
1155            return Err(crate::Error::InvalidConfiguration(
1156                "mesh_relay.max_ttl must be greater than 0".to_string(),
1157            ));
1158        }
1159
1160        if self.mesh_relay.dense_max_ttl == 0 {
1161            return Err(crate::Error::InvalidConfiguration(
1162                "mesh_relay.dense_max_ttl must be greater than 0".to_string(),
1163            ));
1164        }
1165
1166        // A queue that holds nothing refuses every admission: the length is
1167        // always at or over capacity, and eviction has no victim to find, so
1168        // each frame is rejected as queue-full. Nothing is forwarded and the
1169        // only trace is `refused_queue_full`.
1170        if self.mesh_relay.queue_capacity == 0 {
1171            return Err(crate::Error::InvalidConfiguration(
1172                "mesh_relay.queue_capacity must be greater than 0".to_string(),
1173            ));
1174        }
1175
1176        // The rate and burst dials feed token buckets that clamp their inputs
1177        // with `.max(0.0)`. That clamp makes a negative or NaN value safe in
1178        // the arithmetic sense and catastrophic in every other: the bucket
1179        // simply never yields a token, so forwarding stops for good with no
1180        // error and no counter. NaN matters on its own because `f32::max`
1181        // returns the *other* operand for it, so a NaN rate silently becomes
1182        // zero rather than propagating anywhere visible.
1183        //
1184        // Checked as a set: they are the same failure, and naming the field in
1185        // the message is what makes a rejected config actionable.
1186        for (name, value) in [
1187            ("rate_per_sec", self.mesh_relay.rate_per_sec),
1188            ("burst", self.mesh_relay.burst),
1189            ("peer_rate_per_sec", self.mesh_relay.peer_rate_per_sec),
1190            ("peer_burst", self.mesh_relay.peer_burst),
1191        ] {
1192            if !value.is_finite() || value <= 0.0 {
1193                return Err(crate::Error::InvalidConfiguration(format!(
1194                    "mesh_relay.{name} must be finite and greater than 0"
1195                )));
1196            }
1197        }
1198
1199        // Capability bias scales forwarding effort; it must never be able to
1200        // scale it to nothing. Zero would make a low-battery device stop
1201        // forwarding by the back door, which is the partition the bias design
1202        // exists to avoid — refusing to relay is `relay.allow_relay`'s job.
1203        if !self.mesh_relay.bias_min_scale.is_finite()
1204            || self.mesh_relay.bias_min_scale <= 0.0
1205            || self.mesh_relay.bias_min_scale > 1.0
1206        {
1207            return Err(crate::Error::InvalidConfiguration(
1208                "mesh_relay.bias_min_scale must be finite and in (0.0, 1.0]".to_string(),
1209            ));
1210        }
1211
1212        // An inverted jitter window is not a narrow window, it is two separate
1213        // failures. `jitter_for` raises the effective maximum to meet the
1214        // minimum, so the span collapses to a single millisecond and every
1215        // neighbor picks essentially the same delay — losing the spread that
1216        // stops a crowded room transmitting on top of itself, which is the
1217        // whole reason the delay exists. And because the check below reads
1218        // `jitter_max`, an oversized `jitter_min` would slip past the overdue
1219        // cut-off entirely: a ten-minute minimum with the default maximum
1220        // validates clean and abandons every forward.
1221        if self.mesh_relay.jitter_min > self.mesh_relay.jitter_max {
1222            return Err(crate::Error::InvalidConfiguration(format!(
1223                "mesh_relay.jitter_min ({}ms) must not exceed mesh_relay.jitter_max ({}ms)",
1224                self.mesh_relay.jitter_min.as_millis(),
1225                self.mesh_relay.jitter_max.as_millis(),
1226            )));
1227        }
1228
1229        // A forward is abandoned rather than sent once it sits
1230        // `RELAY_QUEUE_MAX_OVERDUE` past its due time, so the delay tunables
1231        // must not be able to reach that on their own. Checked at the density
1232        // the jitter span is configured for; the span also grows with neighbor
1233        // count, which is a runtime quantity this cannot see and a documented
1234        // ceiling of the mesh's venue-sized target rather than a config error.
1235        let worst_delay = self
1236            .mesh_relay
1237            .jitter_max
1238            .saturating_add(self.mesh_relay.bias_max_handicap);
1239        if worst_delay >= RELAY_QUEUE_MAX_OVERDUE {
1240            return Err(crate::Error::InvalidConfiguration(format!(
1241                "mesh_relay.jitter_max + mesh_relay.bias_max_handicap ({}ms) must stay under \
1242                 the {}ms overdue cut-off, or every biased forward is abandoned instead of sent",
1243                worst_delay.as_millis(),
1244                RELAY_QUEUE_MAX_OVERDUE.as_millis(),
1245            )));
1246        }
1247
1248        if self.mesh_relay.activity_window.is_zero() {
1249            return Err(crate::Error::InvalidConfiguration(
1250                "mesh_relay.activity_window must be greater than zero".to_string(),
1251            ));
1252        }
1253
1254        if self.mesh_relay.activity_min_forwards == 0 {
1255            return Err(crate::Error::InvalidConfiguration(
1256                "mesh_relay.activity_min_forwards must be greater than 0".to_string(),
1257            ));
1258        }
1259
1260        if self.mesh_relay.activity_idle_windows == 0 {
1261            return Err(crate::Error::InvalidConfiguration(
1262                "mesh_relay.activity_idle_windows must be greater than 0".to_string(),
1263            ));
1264        }
1265
1266        self.validate_custody()?;
1267
1268        Ok(())
1269    }
1270
1271    /// The custody dials (`docs/spec/custody.md`, "Quotas").
1272    ///
1273    /// A zero hold is refused whatever the switch says: it is not a shorter
1274    /// hold, it is a store that expires everything at the first sweep. The
1275    /// remaining bounds are checked only while custody is enabled. The
1276    /// default hold is six hours, and a disabled section that could refuse a
1277    /// configuration whose outbox lifetime is shorter than that would turn
1278    /// every such configuration, which today holds nothing, into a startup
1279    /// error for a feature it never switched on.
1280    fn validate_custody(&self) -> crate::Result<()> {
1281        let custody = &self.custody;
1282
1283        if custody.hold_ms == 0 {
1284            return Err(crate::Error::InvalidConfiguration(
1285                "custody.hold_ms must be greater than 0".to_string(),
1286            ));
1287        }
1288
1289        if !custody.enabled {
1290            return Ok(());
1291        }
1292
1293        // The inversion this refuses is silent: a custodian whose hold
1294        // outlives the depositor's outbox keeps bytes whose sender has already
1295        // reported the message failed, and delivers a frame to settle an entry
1296        // that no longer exists. Nothing at runtime would notice, because the
1297        // custodian cannot see the depositor's ladder.
1298        let outbox_lifetime_ms = self.reliability.retry.outbox_max_lifetime_ms;
1299        if custody.hold_ms >= outbox_lifetime_ms {
1300            return Err(crate::Error::InvalidConfiguration(format!(
1301                "custody.hold_ms ({}ms) must be strictly shorter than \
1302                 retry.outbox_max_lifetime_ms ({}ms): a hold that outlives the outbox \
1303                 delivers frames whose sender has already reported them failed",
1304                custody.hold_ms, outbox_lifetime_ms,
1305            )));
1306        }
1307
1308        // The session tier is the one that admits anything by default, so a
1309        // zero there is custody switched on and holding nothing: the silent
1310        // dial `enabled` exists to replace.
1311        if custody.max_entries_per_depositor == 0 || custody.max_bytes_per_depositor == 0 {
1312            return Err(crate::Error::InvalidConfiguration(
1313                "custody.max_entries_per_depositor and custody.max_bytes_per_depositor must \
1314                 both be greater than 0 while custody is enabled: zero is not a smaller \
1315                 quota, it is a store that refuses every deposit"
1316                    .to_string(),
1317            ));
1318        }
1319
1320        if custody.max_bytes_per_depositor < CUSTODY_MIN_BYTES_PER_DEPOSITOR {
1321            return Err(crate::Error::InvalidConfiguration(format!(
1322                "custody.max_bytes_per_depositor ({}) must admit one replication frame at \
1323                 its ceiling ({} bytes), or every deposit is refused as depositor_full",
1324                custody.max_bytes_per_depositor, CUSTODY_MIN_BYTES_PER_DEPOSITOR,
1325            )));
1326        }
1327
1328        if custody.max_entries < custody.max_entries_per_depositor
1329            || custody.max_bytes < custody.max_bytes_per_depositor
1330        {
1331            return Err(crate::Error::InvalidConfiguration(
1332                "custody.max_entries and custody.max_bytes must each be at least their \
1333                 per-depositor sibling, or the per-depositor dial can never be reached"
1334                    .to_string(),
1335            ));
1336        }
1337
1338        // The stranger tier is off by both dials or on by both. One at zero
1339        // with the other positive reads like a narrow allowance and is a
1340        // refusal of every stranger, which is the default said in a way an
1341        // operator would not recognise as the default.
1342        let stranger_on = custody.stranger_max_entries > 0 || custody.stranger_max_bytes > 0;
1343        if stranger_on && (custody.stranger_max_entries == 0 || custody.stranger_max_bytes == 0) {
1344            return Err(crate::Error::InvalidConfiguration(
1345                "custody.stranger_max_entries and custody.stranger_max_bytes must be both \
1346                 zero (no stranger deposits) or both greater than 0"
1347                    .to_string(),
1348            ));
1349        }
1350
1351        if stranger_on && custody.stranger_max_bytes < CUSTODY_MIN_BYTES_PER_DEPOSITOR {
1352            return Err(crate::Error::InvalidConfiguration(format!(
1353                "custody.stranger_max_bytes ({}) must admit one replication frame at its \
1354                 ceiling ({} bytes), or every stranger deposit is refused as depositor_full",
1355                custody.stranger_max_bytes, CUSTODY_MIN_BYTES_PER_DEPOSITOR,
1356            )));
1357        }
1358
1359        if custody.max_entries < custody.stranger_max_entries
1360            || custody.max_bytes < custody.stranger_max_bytes
1361        {
1362            return Err(crate::Error::InvalidConfiguration(
1363                "custody.max_entries and custody.max_bytes must each be at least the \
1364                 stranger tier's dial, or that tier can never be reached"
1365                    .to_string(),
1366            ));
1367        }
1368
1369        Ok(())
1370    }
1371}
1372
1373/// Builder for ProtocolConfig with a fluent API.
1374pub struct ProtocolConfigBuilder {
1375    config: ProtocolConfig,
1376}
1377
1378impl ProtocolConfigBuilder {
1379    /// Creates a new builder.
1380    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
1381        Self {
1382            config: ProtocolConfig::new(app_id, profile),
1383        }
1384    }
1385
1386    /// Configures transports.
1387    pub fn transport(mut self, config: TransportConfig) -> Self {
1388        self.config.transport = config;
1389        self
1390    }
1391
1392    /// Enables or disables BLE transport.
1393    pub fn ble_enabled(mut self, enabled: bool) -> Self {
1394        self.config.transport.ble_enabled = enabled;
1395        self
1396    }
1397
1398    /// Enables or disables Wi-Fi Direct transport.
1399    pub fn wifi_direct_enabled(mut self, enabled: bool) -> Self {
1400        self.config.transport.wifi_direct_enabled = enabled;
1401        self
1402    }
1403
1404    /// Enables or disables Internet transport.
1405    pub fn internet_enabled(mut self, enabled: bool) -> Self {
1406        self.config.transport.internet_enabled = enabled;
1407        self
1408    }
1409
1410    /// Enables or disables Nostr relay transport.
1411    pub fn nostr_enabled(mut self, enabled: bool) -> Self {
1412        self.config.transport.nostr_enabled = enabled;
1413        self
1414    }
1415
1416    /// Enables or disables sealing of outgoing Nostr frames (default on).
1417    pub fn nostr_sealing_enabled(mut self, enabled: bool) -> Self {
1418        self.config.transport.nostr_sealing_enabled = enabled;
1419        self
1420    }
1421
1422    /// Enables username discovery publication and resolution
1423    /// (default **off**; see
1424    /// [`TransportConfig::nostr_username_discovery_enabled`]).
1425    pub fn nostr_username_discovery_enabled(mut self, enabled: bool) -> Self {
1426        self.config.transport.nostr_username_discovery_enabled = enabled;
1427        self
1428    }
1429
1430    /// Enables or disables Nostr key-package publication and peer resolution
1431    /// (default on; see [`TransportConfig::nostr_cold_contact_enabled`]).
1432    pub fn nostr_cold_contact_enabled(mut self, enabled: bool) -> Self {
1433        self.config.transport.nostr_cold_contact_enabled = enabled;
1434        self
1435    }
1436
1437    /// Configures DORS (Dynamic Offline Relay Switch).
1438    pub fn dors(mut self, config: DorsConfig) -> Self {
1439        self.config.dors = config;
1440        self
1441    }
1442
1443    /// Enables online-first mode (prefer Internet when available).
1444    pub fn online_first(mut self, enabled: bool) -> Self {
1445        self.config.dors.prefer_online = enabled;
1446        self
1447    }
1448
1449    /// Configures relay management.
1450    pub fn relay(mut self, config: RelayConfig) -> Self {
1451        self.config.relay = config;
1452        self
1453    }
1454
1455    /// Configures how traffic is carried for other people.
1456    pub fn mesh_relay(mut self, config: MeshRelayConfig) -> Self {
1457        self.config.mesh_relay = config;
1458        self
1459    }
1460
1461    /// Configures reliability layer.
1462    pub fn reliability(mut self, config: ReliabilityConfig) -> Self {
1463        self.config.reliability = config;
1464        self
1465    }
1466
1467    /// Sets the initial TTL for messages.
1468    pub fn initial_ttl(mut self, ttl: u8) -> Self {
1469        self.config.initial_ttl = ttl;
1470        self
1471    }
1472
1473    /// Configures encryption settings.
1474    pub fn encryption(mut self, config: EncryptionConfig) -> Self {
1475        self.config.encryption = config;
1476        self
1477    }
1478
1479    /// Enables or disables automatic encryption.
1480    pub fn encryption_enabled(mut self, enabled: bool) -> Self {
1481        self.config.encryption.enabled = enabled;
1482        self
1483    }
1484
1485    /// Enables or disables automatic key exchange on peer discovery.
1486    pub fn auto_key_exchange(mut self, enabled: bool) -> Self {
1487        self.config.encryption.auto_key_exchange = enabled;
1488        self
1489    }
1490
1491    /// Enables or disables storing pending messages for later encryption.
1492    pub fn store_pending_messages(mut self, enabled: bool) -> Self {
1493        self.config.encryption.store_pending = enabled;
1494        self
1495    }
1496
1497    /// Enables or disables strict encryption requirement.
1498    pub fn require_encryption(mut self, required: bool) -> Self {
1499        self.config.encryption.require_encryption = required;
1500        self
1501    }
1502
1503    /// Configures pending encrypted message queue bounds and overflow behavior.
1504    pub fn pending_queue(mut self, config: PendingQueueConfig) -> Self {
1505        self.config.encryption.pending_queue = config;
1506        self
1507    }
1508
1509    /// Configures group messaging settings.
1510    pub fn group(mut self, config: GroupConfig) -> Self {
1511        self.config.group = config;
1512        self
1513    }
1514
1515    /// Sets the maximum number of members allowed in a single group.
1516    pub fn max_group_members(mut self, max: usize) -> Self {
1517        self.config.group.max_group_members = max;
1518        self
1519    }
1520
1521    /// Sets whether groups should attempt relay server registration.
1522    pub fn group_relay_enabled(mut self, enabled: bool) -> Self {
1523        self.config.group.relay_enabled = enabled;
1524        self
1525    }
1526
1527    /// Sets whether relay-synced groups may send via a single relay broadcast
1528    /// instead of per-member fan-out. See
1529    /// [`GroupConfig::relay_broadcast_enabled`] — on by default, but only
1530    /// ever taken against a relay that advertised the `group_delivery_v3`
1531    /// capability, whose settled, address-space delivery report gives the
1532    /// broadcast a per-recipient delivery contract.
1533    pub fn group_relay_broadcast_enabled(mut self, enabled: bool) -> Self {
1534        self.config.group.relay_broadcast_enabled = enabled;
1535        self
1536    }
1537
1538    /// Sets whether unauthorized MLS membership commits are refused rather
1539    /// than applied-and-reported. See
1540    /// [`GroupConfig::enforce_admin_commits`] — off by default, and enabling
1541    /// it risks permanently forking members whose admin overlay disagrees.
1542    pub fn group_enforce_admin_commits(mut self, enabled: bool) -> Self {
1543        self.config.group.enforce_admin_commits = enabled;
1544        self
1545    }
1546
1547    /// Configures security settings.
1548    pub fn security(mut self, config: SecurityConfig) -> Self {
1549        self.config.security = config;
1550        self
1551    }
1552
1553    /// Sets whether transport identity is required for control messages.
1554    pub fn require_transport_identity(mut self, required: bool) -> Self {
1555        self.config.security.require_transport_identity = required;
1556        self
1557    }
1558
1559    /// Configures the replicated-document data layer.
1560    pub fn data(mut self, config: DataConfig) -> Self {
1561        self.config.data = config;
1562        self
1563    }
1564
1565    /// Sets whether the data layer accepts work. See [`DataConfig::enabled`].
1566    pub fn data_enabled(mut self, enabled: bool) -> Self {
1567        self.config.data.enabled = enabled;
1568        self
1569    }
1570
1571    /// Points documents at a storage backend of the application's choosing.
1572    ///
1573    /// The whole bring-your-own-backend path, in one call. See
1574    /// [`DataConfig::storage`], including what it means for logout.
1575    pub fn data_storage(
1576        mut self,
1577        storage: Arc<dyn crate::protocol_state_storage::ProtocolStateStorage>,
1578    ) -> Self {
1579        self.config.data.storage = Some(storage);
1580        self
1581    }
1582
1583    /// Configures custody: whether this device holds a neighbour's replication
1584    /// frames, and under what quotas. See [`CustodyConfig`].
1585    pub fn custody(mut self, config: CustodyConfig) -> Self {
1586        self.config.custody = config;
1587        self
1588    }
1589
1590    /// Builds and validates the configuration.
1591    ///
1592    /// # Returns
1593    ///
1594    /// Returns `Ok(ProtocolConfig)` if valid, `Err` otherwise.
1595    pub fn build(self) -> crate::Result<ProtocolConfig> {
1596        self.config.validate()?;
1597        Ok(self.config)
1598    }
1599}
1600
1601#[cfg(test)]
1602mod tests {
1603    use super::*;
1604
1605    #[test]
1606    fn test_config_creation() {
1607        let config = ProtocolConfig::new("test-app", "user123");
1608        assert_eq!(config.app_id, "test-app");
1609        assert_eq!(config.profile, "user123");
1610        assert_eq!(config.initial_ttl, 8);
1611    }
1612
1613    #[test]
1614    fn test_config_validation_success() {
1615        let config = ProtocolConfig::new("test-app", "user123");
1616        assert!(config.validate().is_ok());
1617    }
1618
1619    #[test]
1620    fn test_config_validation_empty_app_id() {
1621        let config = ProtocolConfig::new("", "user123");
1622        assert!(config.validate().is_err());
1623    }
1624
1625    #[test]
1626    fn test_config_validation_empty_user_id() {
1627        let config = ProtocolConfig::new("test-app", "");
1628        assert!(config.validate().is_err());
1629    }
1630
1631    #[test]
1632    fn config_validation_enforces_the_id_length_cap() {
1633        // An unbounded id becomes an unbounded storage key and an unbounded
1634        // wire field; the cap is the same one the core newtypes apply.
1635        let at_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN);
1636        let over_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN + 1);
1637
1638        assert!(ProtocolConfig::new("test-app", &at_cap).validate().is_ok());
1639        assert!(ProtocolConfig::new(&at_cap, "user123").validate().is_ok());
1640        assert!(ProtocolConfig::new("test-app", &over_cap)
1641            .validate()
1642            .is_err());
1643        assert!(ProtocolConfig::new(&over_cap, "user123")
1644            .validate()
1645            .is_err());
1646    }
1647
1648    #[test]
1649    fn config_validation_enforces_the_id_charset() {
1650        // Path separators and the KV namespace separator would let an id
1651        // escape or collide with another account's storage keys.
1652        for hostile in ["a/b", "a\\b", "a:b", ".", "..", "a\u{0}b", "a\nb"] {
1653            assert!(
1654                ProtocolConfig::new("test-app", hostile).validate().is_err(),
1655                "user_id {hostile:?} must be rejected"
1656            );
1657            assert!(
1658                ProtocolConfig::new(hostile, "user123").validate().is_err(),
1659                "app_id {hostile:?} must be rejected"
1660            );
1661        }
1662    }
1663
1664    #[test]
1665    fn config_validation_accepts_an_address_shaped_id() {
1666        // The self-certifying address format must pass the id policy: the
1667        // bech32m charset is plain lowercase alphanumerics.
1668        let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
1669        assert!(ProtocolConfig::new("test-app", address).validate().is_ok());
1670    }
1671
1672    #[test]
1673    fn test_config_validation_zero_ttl() {
1674        let mut config = ProtocolConfig::new("test-app", "user123");
1675        config.initial_ttl = 0;
1676        assert!(config.validate().is_err());
1677    }
1678
1679    #[test]
1680    fn test_config_validation_no_transports() {
1681        let mut config = ProtocolConfig::new("test-app", "user123");
1682        config.transport.ble_enabled = false;
1683        config.transport.wifi_direct_enabled = false;
1684        config.transport.internet_enabled = false;
1685        assert!(config.validate().is_err());
1686    }
1687
1688    #[test]
1689    fn test_config_validation_retry_initial_delay_must_be_positive() {
1690        let mut config = ProtocolConfig::new("test-app", "user123");
1691        config.reliability.retry.initial_delay_ms = 0;
1692        assert!(config.validate().is_err());
1693    }
1694
1695    #[test]
1696    fn test_config_validation_retry_delay_bounds() {
1697        let mut config = ProtocolConfig::new("test-app", "user123");
1698        config.reliability.retry.initial_delay_ms = 2000;
1699        config.reliability.retry.max_delay_ms = 1000;
1700        assert!(config.validate().is_err());
1701    }
1702
1703    #[test]
1704    fn test_config_validation_retry_backoff_multiplier_bounds() {
1705        let mut config = ProtocolConfig::new("test-app", "user123");
1706        config.reliability.retry.backoff_multiplier = 0.5;
1707        assert!(config.validate().is_err());
1708    }
1709
1710    #[test]
1711    fn test_config_validation_rejects_zero_bloom_parameters() {
1712        let mutations: [fn(&mut DeduplicatorConfig); 4] = [
1713            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_bits = 0,
1714            |dedup: &mut DeduplicatorConfig| dedup.bloom_hash_count = 0,
1715            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_count = 0,
1716            |dedup: &mut DeduplicatorConfig| dedup.bloom_rotation_secs = 0,
1717        ];
1718
1719        for mutate in mutations {
1720            let mut config = ProtocolConfig::new("test-app", "user123");
1721            config.reliability.dedup.use_bloom_filter = true;
1722            mutate(&mut config.reliability.dedup);
1723            assert!(config.validate().is_err());
1724        }
1725    }
1726
1727    #[test]
1728    fn test_config_builder() {
1729        let config = ProtocolConfig::builder("test-app", "user123")
1730            .ble_enabled(true)
1731            .wifi_direct_enabled(false)
1732            .online_first(true)
1733            .initial_ttl(10)
1734            .build()
1735            .unwrap();
1736
1737        assert_eq!(config.app_id, "test-app");
1738        assert!(config.transport.ble_enabled);
1739        assert!(!config.transport.wifi_direct_enabled);
1740        assert!(config.dors.prefer_online);
1741        assert_eq!(config.initial_ttl, 10);
1742    }
1743
1744    #[test]
1745    fn test_transport_config_default() {
1746        let transport = TransportConfig::default();
1747        assert!(transport.ble_enabled);
1748        assert!(transport.wifi_direct_enabled);
1749        assert!(transport.internet_enabled);
1750    }
1751
1752    #[test]
1753    fn test_reliability_config_default() {
1754        let reliability = ReliabilityConfig::default();
1755        assert_eq!(reliability.ack.default_timeout_ms, 10000);
1756        assert_eq!(reliability.retry.max_retries, 10);
1757        // 7 days — must match the app-layer presence-flush window and the
1758        // fallback values hardcoded in the RN native bridges
1759        // (OfflineProtocolModule.kt / OfflineProtocolModule.swift).
1760        assert_eq!(reliability.retry.outbox_max_lifetime_ms, 604_800_000);
1761        // 5 min ceiling — also mirrored by the RN bridge fallbacks.
1762        assert_eq!(reliability.retry.max_delay_ms, 300_000);
1763        // Sized for the persisted seen set: see `DeduplicatorConfig::default`.
1764        assert_eq!(reliability.dedup.max_tracked_messages, 5000);
1765        assert_eq!(reliability.dedup.retention_time_secs, 604_800);
1766    }
1767
1768    #[test]
1769    fn test_encryption_config_default() {
1770        let encryption = EncryptionConfig::default();
1771        assert!(encryption.enabled);
1772        assert!(encryption.auto_key_exchange);
1773        assert!(encryption.store_pending);
1774        // SEC-M3: encryption is required by default; plaintext is an
1775        // explicit opt-out, never a silent fallback.
1776        assert!(encryption.require_encryption);
1777        assert!(encryption.encrypt_service_messages);
1778        assert_eq!(encryption.pending_queue.max_pending_per_peer, 64);
1779        assert_eq!(encryption.pending_queue.max_pending_global, 4096);
1780        assert_eq!(
1781            encryption.pending_queue.pending_ttl_ms,
1782            DEFAULT_PENDING_TTL_MS
1783        );
1784        assert_eq!(
1785            encryption.pending_queue.overflow_policy,
1786            OverflowPolicy::DropOldest
1787        );
1788    }
1789
1790    #[test]
1791    fn test_encryption_config_disabled() {
1792        let encryption = EncryptionConfig::disabled();
1793        assert!(!encryption.enabled);
1794        assert!(!encryption.auto_key_exchange);
1795        assert!(!encryption.store_pending);
1796        assert!(!encryption.require_encryption);
1797        assert!(!encryption.encrypt_service_messages);
1798    }
1799
1800    /// A config serialized before `encrypt_service_messages` existed still
1801    /// parses, and parses with the switch on: without the named default a
1802    /// stored config would fail to load the release that added the field.
1803    #[test]
1804    fn an_encryption_config_without_the_service_switch_parses_with_it_on() {
1805        let mut value =
1806            serde_json::to_value(EncryptionConfig::default()).expect("the config serializes");
1807        value
1808            .as_object_mut()
1809            .expect("an object")
1810            .remove("encrypt_service_messages")
1811            .expect("the field was serialized");
1812        let parsed: EncryptionConfig =
1813            serde_json::from_value(value).expect("an older config still parses");
1814        assert!(parsed.encrypt_service_messages);
1815    }
1816
1817    #[test]
1818    fn test_config_builder_with_encryption() {
1819        let config = ProtocolConfig::builder("test-app", "user123")
1820            .encryption_enabled(true)
1821            .auto_key_exchange(true)
1822            .store_pending_messages(false)
1823            .require_encryption(true)
1824            .pending_queue(PendingQueueConfig {
1825                max_pending_per_peer: 32,
1826                max_pending_global: 512,
1827                pending_ttl_ms: 30_000,
1828                overflow_policy: OverflowPolicy::DropNewest,
1829                ..Default::default()
1830            })
1831            .build()
1832            .unwrap();
1833
1834        assert!(config.encryption.enabled);
1835        assert!(config.encryption.auto_key_exchange);
1836        assert!(!config.encryption.store_pending);
1837        assert!(config.encryption.require_encryption);
1838        assert_eq!(config.encryption.pending_queue.max_pending_per_peer, 32);
1839        assert_eq!(config.encryption.pending_queue.max_pending_global, 512);
1840        assert_eq!(config.encryption.pending_queue.pending_ttl_ms, 30_000);
1841        assert_eq!(
1842            config.encryption.pending_queue.overflow_policy,
1843            OverflowPolicy::DropNewest
1844        );
1845    }
1846
1847    #[test]
1848    fn test_config_validation_require_encryption_requires_enabled() {
1849        let mut config = ProtocolConfig::new("test-app", "user123");
1850        config.encryption.enabled = false;
1851        config.encryption.require_encryption = true;
1852        assert!(config.validate().is_err());
1853    }
1854
1855    #[test]
1856    fn test_config_validation_pending_queue_requires_positive_limits() {
1857        let mut config = ProtocolConfig::new("test-app", "user123");
1858        config.encryption.pending_queue.max_pending_per_peer = 0;
1859        assert!(config.validate().is_err());
1860
1861        let mut config = ProtocolConfig::new("test-app", "user123");
1862        config.encryption.pending_queue.max_pending_global = 0;
1863        assert!(config.validate().is_err());
1864
1865        let mut config = ProtocolConfig::new("test-app", "user123");
1866        config.encryption.pending_queue.pending_ttl_ms = 0;
1867        assert!(config.validate().is_err());
1868    }
1869
1870    #[test]
1871    fn test_config_validation_pending_queue_global_must_cover_per_peer() {
1872        let mut config = ProtocolConfig::new("test-app", "user123");
1873        config.encryption.pending_queue.max_pending_per_peer = 100;
1874        config.encryption.pending_queue.max_pending_global = 99;
1875        assert!(config.validate().is_err());
1876    }
1877
1878    #[test]
1879    fn test_group_config_default() {
1880        let group = GroupConfig::default();
1881        assert_eq!(group.max_group_members, 256);
1882        // Registration and broadcast both default on. Registration must stay
1883        // on because invite links resolve against the relay group registry.
1884        // The broadcast default is safe because the flag alone never selects
1885        // the path: it is additionally gated on the relay advertising the
1886        // `group_delivery_v3` capability, whose settled, address-space
1887        // delivery report is what gives the broadcast a per-recipient
1888        // delivery contract.
1889        assert!(group.relay_enabled);
1890        assert!(group.relay_broadcast_enabled);
1891        // Commit enforcement must stay OFF by default. On, a member whose
1892        // admin overlay merely lagged would refuse a legitimate commit and
1893        // fork itself out of a healthy group with no attacker involved —
1894        // unrecoverable without an app-level re-invite. Flipping this default
1895        // is a fleet-wide partition decision, not a hardening tweak.
1896        assert!(!group.enforce_admin_commits);
1897    }
1898
1899    #[test]
1900    fn test_group_enforce_admin_commits_builder_opt_in() {
1901        let config = ProtocolConfig::builder("test-app", "user123")
1902            .group_enforce_admin_commits(true)
1903            .build()
1904            .unwrap();
1905        assert!(config.group.enforce_admin_commits);
1906        assert!(
1907            config.group.relay_enabled && config.group.relay_broadcast_enabled,
1908            "opting into enforcement must not disturb the relay flags"
1909        );
1910    }
1911
1912    #[test]
1913    fn test_group_relay_broadcast_builder_opt_out() {
1914        let config = ProtocolConfig::builder("test-app", "user123")
1915            .group_relay_broadcast_enabled(false)
1916            .build()
1917            .unwrap();
1918        assert!(!config.group.relay_broadcast_enabled);
1919        assert!(
1920            config.group.relay_enabled,
1921            "opting out of the broadcast must not disturb registration"
1922        );
1923    }
1924
1925    #[test]
1926    fn test_config_has_default_group_config() {
1927        let config = ProtocolConfig::new("test-app", "user123");
1928        assert_eq!(config.group.max_group_members, 256);
1929    }
1930
1931    #[test]
1932    fn test_config_validation_zero_max_group_members() {
1933        let mut config = ProtocolConfig::new("test-app", "user123");
1934        config.group.max_group_members = 0;
1935        assert!(config.validate().is_err());
1936    }
1937
1938    #[test]
1939    fn test_config_validation_positive_max_group_members() {
1940        let mut config = ProtocolConfig::new("test-app", "user123");
1941        config.group.max_group_members = 1;
1942        assert!(config.validate().is_ok());
1943    }
1944
1945    /// A handicap large enough to outrun the overdue cut-off must be refused.
1946    ///
1947    /// Past that point a biased forward is not late, it is *abandoned* on
1948    /// release — so the tunable that exists to cost redundancy would be costing
1949    /// delivery, silently, on exactly the devices it is meant to spare.
1950    #[test]
1951    fn test_config_validation_rejects_a_handicap_past_the_overdue_cutoff() {
1952        let mut config = ProtocolConfig::new("test-app", "user123");
1953        config.mesh_relay.bias_max_handicap = RELAY_QUEUE_MAX_OVERDUE;
1954        assert!(config.validate().is_err());
1955
1956        // The jitter window counts toward the same budget, so a handicap that
1957        // fits on its own can still be refused alongside a wide window.
1958        config.mesh_relay.bias_max_handicap = std::time::Duration::from_millis(400);
1959        config.mesh_relay.jitter_max = RELAY_QUEUE_MAX_OVERDUE;
1960        assert!(config.validate().is_err());
1961
1962        // And the defaults must sit comfortably inside it.
1963        let defaults = ProtocolConfig::new("test-app", "user123");
1964        assert!(defaults.validate().is_ok());
1965    }
1966
1967    /// Bias must never be configurable into an off switch.
1968    ///
1969    /// A scale of zero would stop a low-battery device forwarding by the back
1970    /// door, which is the partition the whole bias design exists to avoid —
1971    /// declining to relay is `relay.allow_relay`'s job, and it is visible.
1972    #[test]
1973    fn test_config_validation_rejects_a_bias_scale_that_silences_forwarding() {
1974        for scale in [0.0, -0.5, 1.5, f32::NAN, f32::INFINITY] {
1975            let mut config = ProtocolConfig::new("test-app", "user123");
1976            config.mesh_relay.bias_min_scale = scale;
1977            assert!(
1978                config.validate().is_err(),
1979                "bias_min_scale of {scale} must be refused",
1980            );
1981        }
1982
1983        // 1.0 is the documented way to disable bias entirely, and must stay
1984        // accepted.
1985        let mut config = ProtocolConfig::new("test-app", "user123");
1986        config.mesh_relay.bias_min_scale = 1.0;
1987        assert!(config.validate().is_ok());
1988    }
1989
1990    /// A fan-out of zero must be refused rather than clamped.
1991    ///
1992    /// It reads like an off switch and is not one: the frame is already
1993    /// admitted and recorded as seen by the time fan-out is chosen, so zero
1994    /// targets is a silent drop that also suppresses the copy arriving by
1995    /// another path. The forwarding paths clamp it to one; this keeps that
1996    /// clamp a defensive floor rather than a quiet reinterpretation.
1997    #[test]
1998    fn test_config_validation_rejects_a_fanout_of_zero() {
1999        let mut config = ProtocolConfig::new("test-app", "user123");
2000        config.mesh_relay.fanout = 0;
2001        assert!(config.validate().is_err());
2002
2003        // One is a real setting — forward to a single neighbor — and must stay
2004        // accepted, or the check has swallowed the narrowest working fan-out.
2005        config.mesh_relay.fanout = 1;
2006        assert!(config.validate().is_ok());
2007    }
2008
2009    /// Every hop-budget, queue and rate dial that can silently switch
2010    /// forwarding off must be refused, not clamped.
2011    ///
2012    /// These share one failure and it is the worst kind: the device keeps
2013    /// running, reports no error, and carries nothing. Each value below is the
2014    /// one a caller would reach for meaning "be conservative", so leaving them
2015    /// legal makes an off switch out of a dial nobody thinks is one.
2016    #[test]
2017    fn test_config_validation_rejects_dials_that_silently_stop_forwarding() {
2018        // Both hop ceilings: zero clamps every arriving budget to nothing, so
2019        // the frame is refused before it can be queued. The dense one is
2020        // checked separately because it only bites in a crowded room.
2021        let mut zero_ttl = ProtocolConfig::new("test-app", "user123");
2022        zero_ttl.mesh_relay.max_ttl = 0;
2023        assert!(zero_ttl.validate().is_err());
2024
2025        let mut zero_dense_ttl = ProtocolConfig::new("test-app", "user123");
2026        zero_dense_ttl.mesh_relay.dense_max_ttl = 0;
2027        assert!(zero_dense_ttl.validate().is_err());
2028
2029        // A queue that holds nothing rejects every admission as queue-full.
2030        let mut zero_queue = ProtocolConfig::new("test-app", "user123");
2031        zero_queue.mesh_relay.queue_capacity = 0;
2032        assert!(zero_queue.validate().is_err());
2033
2034        // The token buckets clamp their inputs with `.max(0.0)`, which turns
2035        // each of these into a bucket that never yields a token. NaN is listed
2036        // on its own because `f32::max` returns the other operand for it, so it
2037        // arrives as zero rather than failing anywhere visible, and negatives
2038        // are what a bridge's own clamp hands over for validation here.
2039        for bad in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
2040            let mut rate = ProtocolConfig::new("test-app", "user123");
2041            rate.mesh_relay.rate_per_sec = bad;
2042            assert!(
2043                rate.validate().is_err(),
2044                "rate_per_sec of {bad} must be refused",
2045            );
2046
2047            let mut burst = ProtocolConfig::new("test-app", "user123");
2048            burst.mesh_relay.burst = bad;
2049            assert!(burst.validate().is_err(), "burst of {bad} must be refused");
2050
2051            let mut peer_rate = ProtocolConfig::new("test-app", "user123");
2052            peer_rate.mesh_relay.peer_rate_per_sec = bad;
2053            assert!(
2054                peer_rate.validate().is_err(),
2055                "peer_rate_per_sec of {bad} must be refused",
2056            );
2057
2058            let mut peer_burst = ProtocolConfig::new("test-app", "user123");
2059            peer_burst.mesh_relay.peer_burst = bad;
2060            assert!(
2061                peer_burst.validate().is_err(),
2062                "peer_burst of {bad} must be refused",
2063            );
2064        }
2065
2066        // The narrowest working settings must stay accepted, or the checks have
2067        // swallowed real configurations along with the broken ones.
2068        let mut minimal = ProtocolConfig::new("test-app", "user123");
2069        minimal.mesh_relay.max_ttl = 1;
2070        minimal.mesh_relay.dense_max_ttl = 1;
2071        minimal.mesh_relay.queue_capacity = 1;
2072        minimal.mesh_relay.rate_per_sec = 0.5;
2073        minimal.mesh_relay.burst = 1.0;
2074        minimal.mesh_relay.peer_rate_per_sec = 0.5;
2075        minimal.mesh_relay.peer_burst = 1.0;
2076        assert!(minimal.validate().is_ok());
2077    }
2078
2079    /// An inverted jitter window must be refused.
2080    ///
2081    /// Two failures in one, which is why the equal case has to stay legal to
2082    /// prove the check is an ordering check and not a ban on narrow windows.
2083    /// `jitter_for` raises the effective maximum to meet the minimum, so the
2084    /// span collapses to a millisecond and neighbors stop separating in time;
2085    /// and since the overdue check reads `jitter_max`, an oversized minimum
2086    /// would otherwise validate clean and abandon every forward.
2087    #[test]
2088    fn test_config_validation_rejects_an_inverted_jitter_window() {
2089        let mut inverted = ProtocolConfig::new("test-app", "user123");
2090        inverted.mesh_relay.jitter_min = std::time::Duration::from_millis(300);
2091        inverted.mesh_relay.jitter_max = std::time::Duration::from_millis(200);
2092        assert!(inverted.validate().is_err());
2093
2094        // The case the overdue check alone would have missed: a minimum past
2095        // the 5s cut-off, with the maximum left at its default.
2096        let mut oversized_min = ProtocolConfig::new("test-app", "user123");
2097        oversized_min.mesh_relay.jitter_min = std::time::Duration::from_secs(600);
2098        assert!(oversized_min.validate().is_err());
2099
2100        // Equal is a fixed delay, not an inversion, and stays accepted.
2101        let mut equal = ProtocolConfig::new("test-app", "user123");
2102        equal.mesh_relay.jitter_min = std::time::Duration::from_millis(200);
2103        equal.mesh_relay.jitter_max = std::time::Duration::from_millis(200);
2104        assert!(equal.validate().is_ok());
2105    }
2106
2107    /// The activity window's tunables decide whether the relay standing can be
2108    /// reported at all; a zero in any of them stalls or divides it.
2109    #[test]
2110    fn test_config_validation_rejects_zero_activity_tunables() {
2111        let mut zero_window = ProtocolConfig::new("test-app", "user123");
2112        zero_window.mesh_relay.activity_window = std::time::Duration::ZERO;
2113        assert!(zero_window.validate().is_err());
2114
2115        let mut zero_forwards = ProtocolConfig::new("test-app", "user123");
2116        zero_forwards.mesh_relay.activity_min_forwards = 0;
2117        assert!(zero_forwards.validate().is_err());
2118
2119        let mut zero_idle = ProtocolConfig::new("test-app", "user123");
2120        zero_idle.mesh_relay.activity_idle_windows = 0;
2121        assert!(zero_idle.validate().is_err());
2122    }
2123
2124    #[test]
2125    fn test_config_builder_max_group_members() {
2126        let config = ProtocolConfig::builder("test-app", "user123")
2127            .max_group_members(50)
2128            .build()
2129            .unwrap();
2130        assert_eq!(config.group.max_group_members, 50);
2131    }
2132
2133    #[test]
2134    fn test_config_builder_group_config() {
2135        let config = ProtocolConfig::builder("test-app", "user123")
2136            .group(GroupConfig {
2137                max_group_members: 128,
2138                ..Default::default()
2139            })
2140            .build()
2141            .unwrap();
2142        assert_eq!(config.group.max_group_members, 128);
2143    }
2144
2145    #[test]
2146    fn test_config_builder_zero_max_group_members_rejected() {
2147        let result = ProtocolConfig::builder("test-app", "user123")
2148            .max_group_members(0)
2149            .build();
2150        assert!(result.is_err());
2151    }
2152
2153    /// The RN bridges never reach [`DEFAULT_PENDING_TTL_MS`]: each layer
2154    /// substitutes its own fallback when the app omits `pendingTtlMs`, and the
2155    /// JS layer always materializes the field before it crosses the bridge. So
2156    /// the Rust default is only the RN default for as long as these three
2157    /// literals agree with it — they silently drifted to the pre-deferred-ACK
2158    /// 2-minute window once already, which is the window this constant was
2159    /// raised to replace.
2160    ///
2161    /// The sibling guard for the ACK/retry fallbacks lives in
2162    /// `offline-protocol-reliability`; this one has to live here because that
2163    /// crate cannot depend on this one.
2164    #[test]
2165    fn rn_bridge_pending_ttl_fallbacks_match_rust_default() {
2166        let rn_root =
2167            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native");
2168
2169        // Each entry is (path, the fallback that file must contain). The
2170        // pattern names the field it guards, not just the literal, so deleting
2171        // the fallback cannot be masked by the same number appearing on some
2172        // other line. Matched against the source with whitespace squeezed, so a
2173        // line wrap between the field and its `?:`/`??` doesn't hide it.
2174        let expected = [
2175            (
2176                rn_root.join("src/index.ts"),
2177                format!("pendingTtlMs ?? {DEFAULT_PENDING_TTL_MS}"),
2178            ),
2179            (
2180                rn_root.join("android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt"),
2181                format!(
2182                    "\"pending_ttl_ms\") ?: {}L",
2183                    format_underscored(DEFAULT_PENDING_TTL_MS)
2184                ),
2185            ),
2186            (
2187                rn_root.join("ios/EncryptionConfigReader.swift"),
2188                format!(
2189                    "\"pending_ttl_ms\") ?? {}",
2190                    format_underscored(DEFAULT_PENDING_TTL_MS)
2191                ),
2192            ),
2193        ];
2194
2195        // The guard only applies in the repo checkout; skip when the bindings
2196        // tree isn't present (e.g. a vendored crate). Read every file up front
2197        // so a moved or renamed bridge skips the whole guard rather than
2198        // silently dropping the checks that come after it.
2199        let Some(sources) = expected
2200            .iter()
2201            .map(|(path, _)| std::fs::read_to_string(path).ok())
2202            .collect::<Option<Vec<_>>>()
2203        else {
2204            eprintln!("bindings tree not present, skipping RN pending-TTL drift check");
2205            return;
2206        };
2207
2208        for ((path, fallback), source) in expected.iter().zip(&sources) {
2209            let squeezed = source.split_whitespace().collect::<Vec<_>>().join(" ");
2210            assert!(
2211                squeezed.contains(fallback.as_str()),
2212                "RN pending-queue TTL fallback drifted from DEFAULT_PENDING_TTL_MS: \
2213                 expected `{fallback}` in {}",
2214                path.display()
2215            );
2216        }
2217    }
2218
2219    /// Drift guard: the React Native `updateDedupConfig` fallbacks match the
2220    /// core dedup defaults. The TypeScript layer forwards only the fields the
2221    /// app set, so a `reliability.dedup` section that sets one field takes the
2222    /// other from these literals, and a fallback that disagrees with the
2223    /// documented default silently changes it for that app. The id cap sat at
2224    /// 10000 against a documented default of 1000, then 2000, that way.
2225    #[test]
2226    fn rn_bridge_dedup_fallbacks_match_rust_default() {
2227        let defaults = ProtocolConfig::new("test-app", "user123").reliability.dedup;
2228        let rn_root =
2229            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native");
2230        // Fallbacks live in the shared bridge (ADR 0025), not the RN module shells.
2231        let kotlin =
2232            rn_root.join("android/src/main/java/com/offlineprotocol/ProtocolEngineHostBridge.kt");
2233        let swift = rn_root.join("ios/ProtocolEngineHostBridge.swift");
2234        let swift_max = format_underscored(defaults.max_tracked_messages as u64);
2235        let swift_retention = format_underscored(defaults.retention_time_secs);
2236        let expected = [
2237            (
2238                kotlin.clone(),
2239                format!(
2240                    "json.optLong(\"maxTrackedMessages\", {})",
2241                    defaults.max_tracked_messages
2242                ),
2243            ),
2244            (
2245                kotlin,
2246                format!(
2247                    "json.optLong(\"retentionTimeSecs\", {})",
2248                    defaults.retention_time_secs
2249                ),
2250            ),
2251            (
2252                swift.clone(),
2253                format!(r#"unsigned64(j, "maxTrackedMessages", {swift_max})"#),
2254            ),
2255            (
2256                swift,
2257                format!(r#"unsigned64(j, "retentionTimeSecs", {swift_retention})"#),
2258            ),
2259        ];
2260
2261        let Some(sources) = expected
2262            .iter()
2263            .map(|(path, _)| std::fs::read_to_string(path).ok())
2264            .collect::<Option<Vec<_>>>()
2265        else {
2266            eprintln!("bindings tree not present, skipping RN dedup fallback drift check");
2267            return;
2268        };
2269
2270        for ((path, fallback), source) in expected.iter().zip(&sources) {
2271            let squeezed = source.split_whitespace().collect::<Vec<_>>().join(" ");
2272            assert!(
2273                squeezed.contains(fallback.as_str()),
2274                "RN dedup fallback drifted from the core default: expected `{fallback}` in {}",
2275                path.display()
2276            );
2277        }
2278    }
2279
2280    /// Drift guard: every public surface that documents
2281    /// [`EncryptionConfig::crypto_recovery_enabled`] must state that the re-key
2282    /// trigger is unauthenticated, and must not carry the retired claim that it
2283    /// re-keys only on genuine peer traffic.
2284    ///
2285    /// This is guarded because it has already drifted once. The trigger was
2286    /// hardened and honestly documented in-code, but that pass corrected only
2287    /// `session.rs` and the internal architecture notes — six integrator-facing
2288    /// surfaces kept promising that an injected frame could never drive a
2289    /// re-key, and framed the residual as replay-only, while
2290    /// `test_forged_frame_reaches_session_desync_without_any_key_material` in
2291    /// `offline-protocol-mls` proved the opposite in the same workspace. Nothing
2292    /// else fails when these lag: the code stays correct and the docs quietly
2293    /// promise a guarantee it does not make, which is the worst shape for a
2294    /// security claim to fail in.
2295    #[test]
2296    fn crypto_recovery_docs_state_the_unauthenticated_trigger() {
2297        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
2298        let surfaces = [
2299            "crates/offline-protocol/src/config.rs",
2300            "crates/offline-protocol-uniffi/src/offline_protocol.udl",
2301            "bindings/react-native/src/types.ts",
2302            "docs/mls-integration.md",
2303            "docs/configuration.md",
2304            "docs/api-reference.md",
2305        ];
2306
2307        // Retired claims. Each was false for the same reason: an MLS epoch is
2308        // read during framing validation, before the sender is authenticated.
2309        // Split across `concat!` so the banned phrases never appear verbatim in
2310        // this file — belt-and-braces on top of the `#[cfg(test)]` truncation
2311        // below, so the guard survives either defense being refactored away.
2312        let retired = [
2313            concat!("never re-keys on ", "injected garbage"),
2314            concat!("injected garbage ", "can't drive"),
2315            concat!("injecting ", "garbage"),
2316        ];
2317
2318        // The marker every surface must carry, concat-split for the inverse
2319        // reason: written verbatim, this needle alone would satisfy its own
2320        // check for config.rs no matter what the rustdoc says.
2321        let marker = concat!("re-key trigger is ", "unauthenticated");
2322
2323        for surface in surfaces {
2324            let path = root.join(surface);
2325            let source = std::fs::read_to_string(&path)
2326                .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
2327            // Scan only up to the first `#[cfg(test)]` token — for config.rs,
2328            // the non-test source. This test necessarily names the marker (doc
2329            // comment, panic message), so scanning its own module would satisfy
2330            // the marker check for the file it lives in even after the rustdoc
2331            // dropped it. The other surfaces don't contain the token and pass
2332            // through whole.
2333            let scanned = source.split("#[cfg(test)]").next().unwrap_or(&source);
2334            // Squeezed so a line wrap inside a phrase cannot hide it, and
2335            // lowercased so a sentence-initial capital cannot either. Standalone
2336            // comment/markup continuation tokens (`///`, `//`, `*`, `>`) are
2337            // dropped, because these six surfaces wrap prose in four different
2338            // comment syntaxes and a reflow would otherwise wedge one into the
2339            // middle of a phrase — a guard that fails on `cargo fmt` teaches
2340            // people to delete it.
2341            let squeezed = scanned
2342                .split_whitespace()
2343                .filter(|tok| !tok.chars().all(|c| matches!(c, '/' | '*' | '>')))
2344                .collect::<Vec<_>>()
2345                .join(" ")
2346                .to_lowercase();
2347
2348            for claim in retired {
2349                assert!(
2350                    !squeezed.contains(claim),
2351                    "{surface} carries the retired claim `{claim}`. The re-key \
2352                     trigger is reachable by anyone who can inject a frame — see \
2353                     test_forged_frame_reaches_session_desync_without_any_key_material"
2354                );
2355            }
2356
2357            assert!(
2358                squeezed.contains(marker),
2359                "{surface} documents crypto_recovery_enabled but no longer states \
2360                 that the re-key trigger is unauthenticated. Say so plainly, or an \
2361                 integrator will read a sustained SESSION_REKEY_TRIGGERED rate as a \
2362                 bug rather than as the injection signal it exists to surface."
2363            );
2364        }
2365    }
2366
2367    /// Renders `86400000` as `86_400_000`, the form Kotlin and Swift use.
2368    fn format_underscored(value: u64) -> String {
2369        let digits = value.to_string();
2370        let mut out = String::new();
2371        for (i, c) in digits.chars().enumerate() {
2372            if i > 0 && (digits.len() - i) % 3 == 0 {
2373                out.push('_');
2374            }
2375            out.push(c);
2376        }
2377        out
2378    }
2379
2380    // ====================================================================
2381    // Custody
2382    // ====================================================================
2383
2384    fn enabled_custody() -> CustodyConfig {
2385        CustodyConfig {
2386            enabled: true,
2387            ..CustodyConfig::default()
2388        }
2389    }
2390
2391    #[test]
2392    fn custody_is_off_by_default_and_the_default_dials_validate_when_on() {
2393        let config = ProtocolConfig::new("app", "user");
2394        assert!(!config.custody.enabled);
2395        assert_eq!(config.custody.hold_ms, DEFAULT_CUSTODY_HOLD_MS);
2396        assert_eq!(config.custody.stranger_max_entries, 0);
2397        assert_eq!(config.custody.stranger_max_bytes, 0);
2398        config.validate().unwrap();
2399
2400        let mut on = ProtocolConfig::new("app", "user");
2401        on.custody = enabled_custody();
2402        on.validate().unwrap();
2403        // The reference defaults respect their own bounds.
2404        assert!(DEFAULT_CUSTODY_HOLD_MS < on.reliability.retry.outbox_max_lifetime_ms);
2405        assert!(DEFAULT_CUSTODY_MAX_BYTES_PER_DEPOSITOR >= CUSTODY_MIN_BYTES_PER_DEPOSITOR);
2406    }
2407
2408    #[test]
2409    fn test_config_validation_rejects_a_custody_hold_that_outlives_the_outbox() {
2410        // The silent inversion: a custodian whose hold outlives the depositor's
2411        // outbox delivers frames whose sender already reported them failed.
2412        let mut config = ProtocolConfig::new("app", "user");
2413        config.custody = enabled_custody();
2414        config.custody.hold_ms = config.reliability.retry.outbox_max_lifetime_ms;
2415        let err = config.validate().unwrap_err().to_string();
2416        assert!(err.contains("custody.hold_ms"), "{err}");
2417        assert!(err.contains("strictly shorter"), "{err}");
2418
2419        config.custody.hold_ms = config.reliability.retry.outbox_max_lifetime_ms - 1;
2420        config.validate().unwrap();
2421
2422        // Disabled, the relation is not checked: a section that is off must
2423        // not refuse a configuration for a feature it never switched on.
2424        config.custody.enabled = false;
2425        config.custody.hold_ms = config.reliability.retry.outbox_max_lifetime_ms * 2;
2426        config.validate().unwrap();
2427
2428        // A zero hold is refused whatever the switch says.
2429        config.custody.hold_ms = 0;
2430        let err = config.validate().unwrap_err().to_string();
2431        assert!(
2432            err.contains("custody.hold_ms must be greater than 0"),
2433            "{err}"
2434        );
2435    }
2436
2437    #[test]
2438    fn test_config_validation_rejects_custody_dials_that_silently_hold_nothing() {
2439        let base = {
2440            let mut config = ProtocolConfig::new("app", "user");
2441            config.custody = enabled_custody();
2442            config
2443        };
2444
2445        let mut zero_entries = base.clone();
2446        zero_entries.custody.max_entries_per_depositor = 0;
2447        assert!(zero_entries
2448            .validate()
2449            .unwrap_err()
2450            .to_string()
2451            .contains("max_entries_per_depositor"));
2452
2453        let mut zero_bytes = base.clone();
2454        zero_bytes.custody.max_bytes_per_depositor = 0;
2455        assert!(zero_bytes
2456            .validate()
2457            .unwrap_err()
2458            .to_string()
2459            .contains("max_bytes_per_depositor"));
2460
2461        let mut too_small = base.clone();
2462        too_small.custody.max_bytes_per_depositor = CUSTODY_MIN_BYTES_PER_DEPOSITOR - 1;
2463        let err = too_small.validate().unwrap_err().to_string();
2464        assert!(err.contains("one replication frame"), "{err}");
2465
2466        let mut inverted = base.clone();
2467        inverted.custody.max_entries = inverted.custody.max_entries_per_depositor - 1;
2468        assert!(inverted
2469            .validate()
2470            .unwrap_err()
2471            .to_string()
2472            .contains("per-depositor sibling"));
2473
2474        let mut inverted_bytes = base.clone();
2475        inverted_bytes.custody.max_bytes = inverted_bytes.custody.max_bytes_per_depositor - 1;
2476        assert!(inverted_bytes
2477            .validate()
2478            .unwrap_err()
2479            .to_string()
2480            .contains("per-depositor sibling"));
2481
2482        // Off, none of it is checked.
2483        let mut off = zero_entries.clone();
2484        off.custody.enabled = false;
2485        off.validate().unwrap();
2486    }
2487
2488    #[test]
2489    fn test_config_validation_holds_the_stranger_tier_to_both_dials() {
2490        let mut config = ProtocolConfig::new("app", "user");
2491        config.custody = enabled_custody();
2492
2493        config.custody.stranger_max_entries = 4;
2494        let err = config.validate().unwrap_err().to_string();
2495        assert!(err.contains("both zero"), "{err}");
2496
2497        config.custody.stranger_max_bytes = CUSTODY_MIN_BYTES_PER_DEPOSITOR - 1;
2498        let err = config.validate().unwrap_err().to_string();
2499        assert!(err.contains("stranger_max_bytes"), "{err}");
2500
2501        config.custody.stranger_max_bytes = CUSTODY_MIN_BYTES_PER_DEPOSITOR;
2502        config.validate().unwrap();
2503
2504        config.custody.stranger_max_entries = config.custody.max_entries + 1;
2505        let err = config.validate().unwrap_err().to_string();
2506        assert!(err.contains("stranger tier"), "{err}");
2507    }
2508
2509    #[test]
2510    fn the_custody_builder_setter_reaches_validation() {
2511        let err = ProtocolConfig::builder("app", "user")
2512            .custody(CustodyConfig {
2513                enabled: true,
2514                hold_ms: u64::MAX,
2515                ..CustodyConfig::default()
2516            })
2517            .build()
2518            .unwrap_err()
2519            .to_string();
2520        assert!(err.contains("custody.hold_ms"), "{err}");
2521    }
2522}