Skip to main content

offline_protocol/protocol/
data_sync.rs

1//! Replicating documents between two peers over the ordinary message ladder.
2//!
3//! # Why this is small
4//!
5//! Almost everything replication needs is already here. Deltas are
6//! idempotent and commutative, so at-least-once, unordered, partition
7//! tolerant delivery is not a compromise this layer works around: it is
8//! exactly enough, by construction. That means no ordering, no sequence
9//! numbers, no session streams, and no second delivery path. A sync frame is
10//! a message, and every reliability property the message ladder has spent
11//! ten releases acquiring becomes a sync property for free.
12//!
13//! # The space is the session
14//!
15//! A space replicates with the peer whose address names it. Inbound frames
16//! take the space from the authenticated wire sender, outbound frames from
17//! the recipient, and neither ever reads a space name off the wire. So a
18//! frame from one peer cannot touch another peer's documents, and there is
19//! no authorization table to get wrong. The two replicas name the same space
20//! differently — each by the *other's* address — which is why the name is
21//! absent from the frames.
22//!
23//! # Anti-entropy, not a protocol
24//!
25//! On session confirm, on reconnect, and at startup, each side offers its
26//! version of every document it holds for that peer. The other side answers
27//! with whatever is missing and its own versions, and the exchange stops.
28//! Nothing persists about how far a peer has got: state that has to be
29//! reconciled after a crash is state that can be wrong, and the version
30//! exchange already answers the only question that matters.
31//!
32//! # Every leg ends
33//!
34//! An inbound frame produces at most one kind of outbound answer, and every
35//! chain of answers is finite: an offer provokes one reply, a delta that
36//! cannot apply provokes one request, and the snapshot that request is
37//! answered with provokes nothing at all. The one chain longer than a single
38//! hop is a reply naming a document this device has never seen: it is asked
39//! for, and the answer is the document. That ends too, because the question
40//! names only documents the peer has just offered, so the peer creates
41//! nothing from it and has nothing further to ask. That is the property to
42//! preserve when adding a frame kind. Two replicas that answer each other's
43//! answers converge perfectly well and then talk until the battery dies, and
44//! the failure has no symptom on either device except traffic.
45//!
46//! A removal carried by an offer does not lengthen any of this. It is a
47//! field on a frame that was already answered exactly once, and what it
48//! provokes is a deletion rather than a frame: the answer to an offer
49//! carrying removals is the same single reply an offer without them draws.
50//! The one thing it must not do is revive what it just removed, which is why
51//! removals are applied before anything is created or asked for.
52//!
53//! One outbound frame is not an answer and does not count against this: a
54//! blob arriving for a document with local edits still pending flushes them
55//! first, which pushes them. That is work this device already owed the peer
56//! and the frame merely reached it, so it cannot recur: what it sends is
57//! what was pending before the frame arrived, and flushing is what stops it
58//! being pending. When that flush fails and the import then applies, the
59//! edit may have been folded into the imported change and suppressed with
60//! it, so a version offer is sent instead of the delta. That one is not an
61//! answer either, and it cannot recur for the same reason: it costs a
62//! storage failure that recovered inside a single frame.
63//!
64//! # Remote bytes are not our bytes
65//!
66//! Everywhere else in the data layer, the argument for handing bytes to the
67//! document engine is that they came back out of a sealed record whose AEAD
68//! tag already vouched for them. That argument does not survive contact with
69//! a network. MLS says who sent a blob; it says nothing about its shape, and
70//! the engine has an open defect where a malformed one aborts the process
71//! rather than returning an error. So blobs arriving here are judged before
72//! the engine sees them, and the one that gets through is remembered on disk
73//! until it has survived, so a blob that kills the process kills it once
74//! rather than every time the sender retries.
75
76use std::collections::{BTreeMap, BTreeSet};
77use std::time::{Duration, Instant};
78
79use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
80use offline_protocol_data::{CatchUp, RemoteImport, VersionToken};
81use serde::{Deserialize, Serialize};
82use sha2::{Digest, Sha256};
83use tracing::{debug, warn};
84
85use crate::error::{Error, Result};
86use crate::events::Event;
87use crate::media_envelope::DataPurpose;
88use crate::protocol::prefixes::internal_prefixes;
89use crate::protocol::types::{storage_keys, MediaSendOptions, DATA_SYNC_V1};
90use crate::protocol::OfflineProtocol;
91use offline_protocol_core::ContentType;
92
93/// Largest document blob carried inside one sync frame, before base64.
94///
95/// Sized to the mesh rather than to the record store. An unnegotiated
96/// Bluetooth link fragments a message into at most 512 pieces of 139 usable
97/// bytes, so roughly 69 KiB is the hard ceiling for a single message on the
98/// worst transport we ship; 32 KiB leaves room for base64 expansion, the
99/// frame's own JSON, the sealed envelope and the message header, and still
100/// arrives in one piece. Anything larger is not refused forever, it is
101/// answered by a snapshot offer that the media path will carry.
102pub(crate) const MAX_SYNC_BLOB_BYTES: usize = 32 * 1024;
103
104/// Documents listed in one version frame.
105///
106/// A name is capped at 128 bytes and an encoded version is short, so this
107/// keeps a version frame comfortably inside [`MAX_SYNC_BLOB_BYTES`] without
108/// having to measure it. A space with more documents than this sends
109/// several frames, which converges identically: the exchange is per
110/// document, not per frame.
111///
112/// The declared interest rides every frame and is not weighed here, because
113/// it is bounded where it is set: [`MAX_INTEREST_PATTERNS`] names of at most
114/// [`MAX_NAME_LEN`] bytes is about 4 KiB, which this budget leaves free. A
115/// wider bound on either would have to be weighed rather than assumed.
116///
117/// [`MAX_INTEREST_PATTERNS`]: super::data::MAX_INTEREST_PATTERNS
118/// [`MAX_NAME_LEN`]: offline_protocol_data::MAX_NAME_LEN
119const MAX_DOCS_PER_VERSION_FRAME: usize = 128;
120
121/// How long an offer to one peer suppresses the next one.
122///
123/// Sized to discovery flapping rather than to editing: a change made locally
124/// is pushed the moment it is durable and does not wait for this, so the only
125/// thing the window delays is the reconciliation sweep, and the next trigger
126/// repeats it anyway.
127pub(crate) const DATA_SYNC_OFFER_INTERVAL: Duration = Duration::from_secs(30);
128
129/// How long local commits to a space must stay quiet before the committing
130/// device sends one follow-up version offer for what it committed: the
131/// settle offer.
132///
133/// A delta that parks behind a lost predecessor is noticed when the *next*
134/// delta arrives, so a loss in the middle of a burst of edits heals at once.
135/// The last delta of a burst has no successor. What recovers it otherwise is
136/// the delivery ladder: every sync frame, 1:1 or group (fanned out per
137/// member), is acknowledged, and an unacknowledged one is re-sent after the
138/// ACK timeout (10 s by default) and its backoff, until the retry budget is
139/// spent. The settle offer asks after [`DATA_SYNC_SETTLE_DELAY`] instead, and
140/// still asks once that budget is gone. Congestion is what loses deltas and it
141/// strikes during bursts, so the tail is not a corner case.
142///
143/// Debounced rather than sent per commit: a commit inside the window pushes
144/// the deadline out, so a burst costs one offer at the end of it, not one per
145/// flush. Three seconds is longer than the gap between flushes of someone
146/// still typing on the recommended throttle, so it fires at a pause rather
147/// than mid-sentence, and short enough that a lost tail is back before the
148/// reader has looked away. It also leaves the burst ahead of it time to drain
149/// a slow link, since the offer queues behind it; one that overtakes costs a
150/// duplicate delta, which the merge absorbs.
151pub(crate) const DATA_SYNC_SETTLE_DELAY: Duration = Duration::from_secs(3);
152
153/// Documents one space may hold on a peer's say-so.
154///
155/// Every name in an offer that this device does not recognise becomes a
156/// stored document, and nothing else bounds how many names one exchange can
157/// carry. The ceiling is far above what two people sharing documents reach
158/// and far below what a peer streaming fresh names could otherwise spend, so
159/// it is a bound on abuse rather than a product limit. It applies only to
160/// documents a peer names: an application creating its own is not restricted
161/// by it, and those still replicate outward.
162pub(crate) const MAX_DOCS_PER_SPACE: usize = 1024;
163
164/// Bytes a removal floor may occupy, on the wire and in the record.
165///
166/// A floor is one version vector, and the floor held for a name is the
167/// union of every floor ever merged into it, which only grows: a member of
168/// a space can grow it by naming a fresh replica in every offer, and nothing
169/// but this stops the record and every comparison against it growing with
170/// it. Sized well past a real document: the version of a document edited
171/// across thousands of sessions is still under this, and a document whose
172/// version could not fit here could not be offered in a frame either.
173pub(crate) const MAX_REMOVAL_FLOOR_BYTES: usize = 64 * 1024;
174
175/// Blob digests remembered per space after a crash.
176///
177/// Small on purpose. The list only has to outlive the sender's retries of
178/// one blob, and every entry in it is a document change we have decided
179/// never to apply, so a long list is a liability rather than a safety net.
180pub(crate) const MAX_QUARANTINED_BLOBS: usize = 32;
181
182/// Largest document encoding this device will carry over the media path.
183///
184/// The record ceiling, because that is the real limit: a document larger
185/// than one sealed protocol-state record cannot be persisted by the receiver
186/// even if every byte arrives, so carrying it would spend a long transfer to
187/// fail at the end. Well above the 1 MiB compacted cap a document is held
188/// to, which leaves room for the history a raw snapshot carries.
189pub(crate) const MAX_MEDIA_SNAPSHOT_BYTES: usize = super::types::MAX_PROTOCOL_STATE_RECORD_BYTES;
190
191/// Attachment fetches one device may have outstanding at once.
192///
193/// Bounded because each entry is created by a local call and cleared by a
194/// remote answer that may never come. Passing the bound evicts the oldest:
195/// a fetch that has been waiting longer than sixty-three others is not the
196/// one still worth remembering.
197pub(crate) const MAX_PENDING_ATTACHMENT_FETCHES: usize = 64;
198
199/// How long a fetch stays outstanding with no sign of an answer.
200///
201/// Measured from the last sign of life rather than from the question, and
202/// the difference is the whole of it: chunks of the answer refresh the
203/// entry, so a blob slower to arrive than this does not have its own record
204/// expire underneath it. That record is what admits the bytes at the end, so
205/// an expiry mid-carriage discards a blob that fully arrived, and the retry
206/// it invites takes just as long and dies exactly the same way.
207///
208/// Generous even so, because a holder may take a while to answer at all and
209/// the media path may be Bluetooth. The cost of being slow here is a
210/// reference that reports unavailable while somebody is still deciding, and
211/// the app can ask again; the cost of no timeout at all is a map that only
212/// grows.
213pub(crate) const ATTACHMENT_FETCH_TIMEOUT: Duration = Duration::from_secs(15 * 60);
214
215/// Blob requests one peer may have acted on inside a window.
216///
217/// A budget rather than only a per-hash window, because the two bound
218/// different things and only one of them is a hash we have seen before. The
219/// per-hash window stops a peer re-asking for the same blob; nothing in it
220/// stops a peer asking for a different one every time, and each of those
221/// costs a durable map entry and an application callback. A hash is 32 bytes
222/// the sender chooses, so without a budget the key space is the whole output
223/// of SHA-256 and the cost of exhausting this device's memory is one frame
224/// per entry.
225pub(crate) const MAX_BLOB_REQUESTS_PER_WINDOW: usize = 32;
226
227/// Blob-request windows remembered across all peers.
228///
229/// The hard bound under the per-peer budget above, and the one that holds
230/// when a peer's budget resets. Oldest out first: a window that has been
231/// held longer than 255 others has almost certainly expired anyway.
232pub(crate) const MAX_BLOB_REQUEST_WINDOWS: usize = 256;
233
234/// One sync frame's body, inside the decrypted MLS plaintext.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(tag = "k")]
237enum SyncBody {
238    /// "Here is what I have; send me what I am missing."
239    ///
240    /// `reply` marks the answering leg, which is what stops the exchange:
241    /// an offer provokes an answer, and an answer provokes nothing.
242    #[serde(rename = "vv")]
243    Versions {
244        #[serde(default)]
245        reply: bool,
246        /// Whether this frame carries less than everything the sender holds:
247        /// one frame of an offer split across several, or a single document
248        /// named because something about it needs answering.
249        ///
250        /// It exists because a receiver reads a document's *absence* from an
251        /// offer as "they have never seen this", and answers with the whole
252        /// document. That inference needs the complete list. Drawn from one
253        /// frame of a split offer it fires on every document the frame did
254        /// not happen to carry, so a peer holding more documents than one
255        /// frame fits would be sent the entire space, in full, on every
256        /// exchange, while perfectly in sync.
257        #[serde(default)]
258        partial: bool,
259        /// Document name to base64 of its version token.
260        docs: BTreeMap<String, String>,
261        /// Document name to base64 of the version it was removed at.
262        ///
263        /// A removal floor rather than a bare "this is gone": it names the
264        /// content the removal covered, so a replica holding an edit beyond
265        /// it keeps its copy and hands the document back, while one holding
266        /// nothing newer deletes it. That is what makes a removal and a
267        /// concurrent edit resolve the same way on every replica without
268        /// either side knowing which happened first.
269        ///
270        /// Absent means "no removals to report", which is what every frame
271        /// from a build that predates them says, and what a space nobody
272        /// has removed anything from says today. Entries are carried for
273        /// names that came back as well as names that are still gone: a
274        /// third replica that never heard about the removal is holding
275        /// exactly the content the floor names.
276        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
277        gone: BTreeMap<String, String>,
278        /// Which documents the sender wants back, as names or `name*`
279        /// prefixes.
280        ///
281        /// `Option` rather than a plain list because absent and empty mean
282        /// opposite things. Absent is "everything", which is what a sender
283        /// that has never narrowed says and what every build that predates
284        /// the field says; empty is "nothing", which an application can ask
285        /// for and must be able to express. Collapsing the two would make
286        /// the narrowest possible request read as the widest.
287        ///
288        /// It scopes what the *receiver* sends back, and nothing else. It is
289        /// a request, not a permission: the sender refuses what it does not
290        /// want on arrival regardless, which is what makes it safe toward a
291        /// peer that ignores the field.
292        #[serde(default, skip_serializing_if = "Option::is_none")]
293        want: Option<Vec<String>>,
294    },
295    /// A run of changes, base64.
296    #[serde(rename = "delta")]
297    Delta { doc: String, blob: String },
298    /// A whole document, base64. The answer to a gap no run of changes can
299    /// express.
300    #[serde(rename = "snap")]
301    Snapshot { doc: String, blob: String },
302    /// "No run of changes can close my gap in this document; send the whole
303    /// thing."
304    ///
305    /// The frame that makes a refusal recoverable. Without it a receiver
306    /// that declines a delta for reaching below its trim point has no way to
307    /// say what would work: answering with a version offer instead asks the
308    /// sender to compute changes since our version, which is the same
309    /// refused delta again, for as long as both sides keep at it.
310    #[serde(rename = "need_snap")]
311    NeedSnapshot { doc: String },
312    /// "I hold a reference to these bytes and not the bytes; do you have
313    /// them?"
314    ///
315    /// Carries no document name. A blob is addressed by what it is rather
316    /// than by where a reference to it happens to sit, so the same bytes
317    /// referenced from three documents are one blob and one request.
318    #[serde(rename = "need_blob")]
319    NeedBlob {
320        hash: String,
321        /// The member this question is for, inside a group.
322        ///
323        /// Absent on a 1:1 session, where the recipient is the only peer
324        /// there is. In a group it is load-bearing rather than a
325        /// convenience: a directed frame is promoted to a roster-wide
326        /// delivery whenever the sender's ratchet budget runs out, and a
327        /// receiver cannot tell a promoted frame from one addressed to it.
328        /// Without this field every member's application would be asked for
329        /// bytes the asker can only accept from one of them.
330        #[serde(default, skip_serializing_if = "Option::is_none")]
331        to: Option<String>,
332    },
333    /// "I do not have those bytes, and asking again will not change that."
334    ///
335    /// The frame that lets a fetch end. An attachment reference outlives the
336    /// bytes it names, because the reference replicates and the bytes do
337    /// not, so a peer holding a reference and no blob is ordinary rather
338    /// than broken. Without an answer for it the asking side cannot tell
339    /// that case from a peer that is merely slow, and shows a spinner
340    /// forever.
341    #[serde(rename = "blob_gone")]
342    BlobGone { hash: String },
343    /// One piece of a blob, travelling as a frame rather than as a
344    /// transfer.
345    ///
346    /// The group answer to `need_blob`. A 1:1 fetch rides the media path,
347    /// which needs a confirmed pairwise session; two members of a group need
348    /// not have one with each other, and requiring it would make an
349    /// attachment depend on a handshake that may never happen. Frames need
350    /// only the group key, which membership already is.
351    ///
352    /// `i` is the index and `n` the count, so a receiver knows when it is
353    /// done without the sender saying so twice. Carrying the count on every
354    /// chunk rather than on the first is what makes the pieces independent:
355    /// they may arrive in any order, and a duplicate replaces rather than
356    /// appends.
357    ///
358    /// Provokes nothing, which is the property that lets `need_blob` stay a
359    /// single-hop question. That is what bounds a blob to
360    /// [`MAX_GROUP_BLOB_CHUNKS`] pieces: a larger one would need the
361    /// requester to ask again for each window, and a chain of questions is
362    /// exactly what this layer does not have.
363    #[serde(rename = "chunk")]
364    Chunk {
365        hash: String,
366        i: u32,
367        n: u32,
368        blob: String,
369    },
370}
371
372/// One name in a version offer: its version when we hold it, and its removal
373/// floor when it has one. A name can carry both, because a document that came
374/// back after a removal is offered *and* still reports the floor, for the
375/// sake of a third replica that never heard about the removal.
376type OfferEntry = (String, Option<String>, Option<String>);
377
378/// Split an offer into frames, each within [`MAX_DOCS_PER_VERSION_FRAME`].
379///
380/// A name carrying both a version and a floor counts twice: the budget
381/// stands in for bytes, and such a name costs the frame two encoded
382/// versions and its own name twice. Counting it once would let a space
383/// whose every document came back after a removal build a frame twice the
384/// size the budget was chosen to keep inside the transport.
385///
386/// An empty offer is still one frame. It is what tells a peer holding
387/// documents to send them to a replica that has never seen any.
388fn offer_batches(entries: &[OfferEntry]) -> Vec<&[OfferEntry]> {
389    let mut batches = Vec::new();
390    let mut start = 0usize;
391    let mut weight = 0usize;
392    for (index, (_, version, floor)) in entries.iter().enumerate() {
393        let cost = usize::from(version.is_some()) + usize::from(floor.is_some());
394        if index > start && weight + cost > MAX_DOCS_PER_VERSION_FRAME {
395            batches.push(&entries[start..index]);
396            start = index;
397            weight = 0;
398        }
399        weight += cost;
400    }
401    batches.push(&entries[start..]);
402    batches
403}
404
405/// One fetch this device is waiting on.
406///
407/// The record both bounds the wait and admits the answer, which is why its
408/// clock is the last sign of life rather than the moment of the question: a
409/// record that expires mid-carriage discards a blob that fully arrived, and
410/// the retry it invites dies at the same point.
411#[derive(Debug)]
412pub(crate) struct PendingFetch {
413    /// The last sign that an answer is coming.
414    pub(crate) last_seen: Instant,
415    /// The member being asked, when the space is a group.
416    ///
417    /// `None` on a 1:1 space, where the peer *is* the space and there is
418    /// nothing to choose. In a group there is: the reference names bytes and
419    /// not who holds them, so the application says which member to ask.
420    pub(crate) holder: Option<String>,
421    /// Chunks accepted so far, when the bytes travel as frames.
422    ///
423    /// Only a group fetch assembles here. A 1:1 fetch rides the media path,
424    /// which does its own reassembly and hands over one finished blob.
425    pub(crate) assembly: Option<BlobAssembly>,
426}
427
428impl PendingFetch {
429    /// A fetch just put to a 1:1 peer.
430    fn to_peer() -> Self {
431        Self {
432            last_seen: Instant::now(),
433            holder: None,
434            assembly: None,
435        }
436    }
437
438    /// A fetch just put to one member of a group.
439    fn to_member(member: &str) -> Self {
440        Self {
441            last_seen: Instant::now(),
442            holder: Some(member.to_string()),
443            assembly: None,
444        }
445    }
446
447    /// Whether `sender` is who this fetch is waiting on.
448    fn is_answered_by(&self, space: &str, sender: &str) -> bool {
449        match &self.holder {
450            Some(member) => member == sender,
451            None => space == sender,
452        }
453    }
454}
455
456/// A blob being reassembled from frames.
457///
458/// What it can hold is bounded by its own two rules rather than by a
459/// running total: at most [`MAX_GROUP_BLOB_CHUNKS`] chunks, since an index
460/// outside the declared count is refused and the count itself is capped,
461/// each at most [`MAX_SYNC_BLOB_BYTES`]. One assembly is therefore 1 MiB at
462/// the very most, and the fetch bound puts a ceiling on how many exist.
463#[derive(Debug, Default)]
464pub(crate) struct BlobAssembly {
465    /// How many chunks the first arrival said there would be.
466    total: u32,
467    /// Chunks by index, so a duplicate replaces rather than appends and the
468    /// order they arrive in does not matter.
469    chunks: BTreeMap<u32, Vec<u8>>,
470}
471
472/// What one version offer says.
473///
474/// The three fields travel together in both directions and mean the same
475/// thing in both: what the sender holds, what it removed, and what it wants
476/// back. Grouping them is not tidiness: passed separately they are three
477/// arguments a caller can transpose, and two of them have the same type.
478#[derive(Debug, Default)]
479struct OfferContents {
480    /// Document name to base64 of the version held.
481    docs: BTreeMap<String, String>,
482    /// Document name to base64 of the version it was removed at.
483    gone: BTreeMap<String, String>,
484    /// Names and `name*` prefixes the sender wants back, or `None` for
485    /// everything.
486    want: Option<Vec<String>>,
487}
488
489/// How many interest patterns one frame is read from.
490///
491/// A peer chooses these, and every one of them is walked per document when
492/// the answer is built, so the bound is the receiver's rather than the
493/// sender's: a sender's own list is bounded where it is set.
494const MAX_INTEREST_PATTERNS_PER_FRAME: usize = 32;
495
496/// The two bounds are the same number for a reason, and the reason is not
497/// coincidence: a pattern an application is allowed to declare has to be one
498/// a conforming peer actually reads. Raised past this on the sending side
499/// alone, the tail of every declared list would be dropped by every peer
500/// with no symptom on either device, and the frame-budget note on
501/// [`MAX_DOCS_PER_VERSION_FRAME`] would be reasoning about a list longer than
502/// it weighs.
503const _: () = assert!(
504    super::data::MAX_INTEREST_PATTERNS <= MAX_INTEREST_PATTERNS_PER_FRAME,
505    "a pattern an application may declare must be one every peer reads"
506);
507
508/// How many frames one blob may be carried in inside a group.
509///
510/// The bound that keeps `need_blob` a single-hop question: the whole answer
511/// leaves at once, so nothing has to ask for the next window and no chain of
512/// questions exists to terminate. At [`MAX_SYNC_BLOB_BYTES`] a piece, this
513/// puts a group attachment at 1 MiB.
514///
515/// That is far below what the 1:1 path carries, and deliberately so. Moving
516/// a hundred megabytes this way would mean flow control, a holder keeping
517/// the bytes between windows, and a re-ask clock, which is the machinery the
518/// media path already has and that a frame path would have to grow. A group
519/// attachment is a receipt, a signature, a small photo; anything larger
520/// travels 1:1, and the sender is told which it is at the call.
521pub(crate) const MAX_GROUP_BLOB_CHUNKS: u32 = 32;
522
523/// Where a sync frame goes and what it is sealed under.
524///
525/// The one thing F4 added to this module. 1:1 replication could treat the
526/// space, the peer, and the reply address as a single value because they
527/// genuinely were one; a group space is one scope with many members, so the
528/// space no longer names the recipient and the two have to be carried
529/// separately.
530#[derive(Debug, Clone)]
531pub(crate) enum SyncChannel {
532    /// A 1:1 space, sealed to the peer the space is named after.
533    Peer,
534    /// A group space, sealed once for the whole roster.
535    ///
536    /// Used only for changes every member needs: a local commit. Group MLS
537    /// produces one ciphertext for everyone, which is what makes group
538    /// replication cheap, and it is also why this must never be used for an
539    /// answer to one member's question — the other members would each
540    /// receive, decrypt, and import a blob they already had.
541    GroupBroadcast,
542    /// A group space, sealed for the roster but delivered to one member.
543    ///
544    /// The reply leg. Anti-entropy between two members of a group is still
545    /// a conversation between two devices: an offer is answered by the
546    /// member that received it and by nobody else. Every other member is
547    /// spared the traffic, and the terminating 1.5-round-trip exchange the
548    /// 1:1 path relies on keeps working unchanged.
549    ///
550    /// Sealed under the group key rather than the pair's 1:1 session on
551    /// purpose: two members of a group need not have a 1:1 session at all,
552    /// and requiring one would make replication depend on a handshake that
553    /// may never happen.
554    GroupDirected(String),
555}
556
557/// Which frame a blob arrived in.
558///
559/// Carried into the import so a rung of the catch-up ladder knows whether
560/// there is a rung above it. A delta that cannot apply has one; a snapshot
561/// is already the top, and asking again returns the same bytes.
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563enum BlobKind {
564    Delta,
565    Snapshot,
566}
567
568/// Per-space replication bookkeeping.
569///
570/// Everything here is about surviving a crash. Nothing in it is needed for
571/// convergence, which is deliberate: the version exchange is the recovery
572/// mechanism, so this record can be lost without costing a change.
573#[derive(Debug, Clone, Default, Serialize, Deserialize)]
574struct SyncRecord {
575    /// The blob currently being handed to the document engine.
576    ///
577    /// Written before the engine is called and cleared after it returns. A
578    /// value still here at open means the last process to touch this space
579    /// did not survive that call.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    in_flight: Option<String>,
582    /// Blobs that were in flight when a previous run died, newest last.
583    #[serde(default, skip_serializing_if = "Vec::is_empty")]
584    quarantined: Vec<String>,
585}
586
587/// The digest a blob is remembered by.
588pub(crate) fn blob_digest(blob: &[u8]) -> String {
589    let mut hasher = Sha256::new();
590    hasher.update(blob);
591    // Half a SHA-256 is 128 bits, which is far past what a collision would
592    // need to be worth engineering: the prize is having one specific blob
593    // refused on one device.
594    hex_encode(&hasher.finalize()[..16])
595}
596
597/// The rate-limit key for offers toward one member of one group.
598///
599/// A group space and a 1:1 space share one window map, so the two key
600/// spaces must not overlap: a bare peer address is the 1:1 key, and this
601/// one is deliberately built with a character no validated peer id, group
602/// id or address can contain, so no composed key can ever collide with a
603/// peer's own.
604fn group_offer_key(member: &str, group_id: &str) -> String {
605    format!("{member}{GROUP_OFFER_KEY_SEP}{group_id}")
606}
607
608/// The separator [`group_offer_key`] composes with, named so the split that
609/// takes a key back apart cannot drift from the format that built it.
610const GROUP_OFFER_KEY_SEP: char = '\x01';
611
612fn hex_encode(bytes: &[u8]) -> String {
613    bytes.iter().map(|b| format!("{b:02x}")).collect()
614}
615
616impl OfflineProtocol {
617    // ---- outbound ---------------------------------------------------
618
619    /// Offer our versions of every document shared with `peer`.
620    ///
621    /// The entry point for all three triggers (session confirm, reconnect,
622    /// startup). Cheap and safe to call spuriously: a peer that is already
623    /// current answers with nothing.
624    pub(crate) fn kick_data_sync(&mut self, peer: &str, cause: &str) {
625        if !self.data_sync_active(peer) {
626            return;
627        }
628        if !self.data_sync_offer_due(peer) {
629            return;
630        }
631        self.offer_versions(peer, cause);
632    }
633
634    /// Offer our versions of every document in `group_id` to one member.
635    ///
636    /// The group counterpart of [`Self::kick_data_sync`], and addressed for
637    /// the same reason its answers are: a member coming back into range is
638    /// a conversation with that member. Broadcasting the offer to the whole
639    /// roster would have every other member answer a question nobody asked
640    /// them.
641    ///
642    /// Rate-limited per (member, group) rather than per member, so a peer
643    /// shared across several groups reconciles all of them rather than
644    /// whichever one its rediscovery happened to reach first.
645    #[cfg_attr(not(feature = "data"), allow(dead_code))]
646    pub(crate) fn kick_group_data_sync(&mut self, group_id: &str, member: &str, cause: &str) {
647        let members = match self.group_roster(group_id) {
648            Ok(members) => members,
649            Err(_) => return,
650        };
651        if !self.group_data_sync_active(&members) {
652            // Rediscovery is the one trigger a group space has that does
653            // not require somebody to be editing, so it is also the only
654            // moment a device that never writes anything will notice the
655            // gate is shut. Probing only from the local-commit path would
656            // leave a member that merely reads waiting for someone else to
657            // do it.
658            self.probe_group_data_capabilities(group_id, &members);
659            return;
660        }
661        let key = group_offer_key(member, group_id);
662        if !self.data_sync_offer_due(&key) {
663            return;
664        }
665        self.offer_versions_over(
666            group_id,
667            &SyncChannel::GroupDirected(member.to_string()),
668            cause,
669        );
670    }
671
672    /// Ask the members holding a group's replication gate closed what they
673    /// support.
674    ///
675    /// For a group the usual reason the gate is shut is not that somebody
676    /// opted out but that we have never heard from them: members added by a
677    /// third party exchange no key packages with us, and an inviter running
678    /// an SDK from before attestation forwards none. Sending them ours
679    /// makes their automatic reply teach us what they support.
680    ///
681    /// The probe itself is capability-agnostic and guarded by
682    /// `key_package_sent_to`, so a group that stays closed does not re-probe
683    /// on every commit or every rediscovery. A member already probed is
684    /// pushed to again only by `rearm_key_package_for_peer`, on its doubling
685    /// backoff, so a closed group costs at most one push per member per
686    /// window.
687    #[cfg_attr(not(feature = "data"), allow(dead_code))]
688    fn probe_group_data_capabilities(&mut self, space: &str, members: &[String]) {
689        let unknown = self.group_data_unknown_members(members);
690        if unknown.is_empty() {
691            return;
692        }
693        debug!(
694            space,
695            unknown = unknown.len(),
696            "Group replication is held closed by members of unknown capability; probing"
697        );
698        self.backfill_group_rich_capabilities(&unknown);
699    }
700
701    /// Drop every offer window belonging to `peer`: the 1:1 one, and the
702    /// group one for each group shared with them.
703    ///
704    /// The group windows are keyed by (member, group), so removing the bare
705    /// peer key leaves them all behind, and each of them suppresses the
706    /// first group offer to that peer for a further window once the
707    /// capability is relearned. That is the same silent non-sync
708    /// [`Self::forget_data_sync_peer`] exists to prevent, one space at a
709    /// time.
710    ///
711    /// Windows left by a group this device *left* are deliberately not
712    /// swept here: they name a scope that no longer replicates at all, and
713    /// on a re-join the worst they can cost is one suppressed offer that
714    /// the next discovery re-sends.
715    #[cfg(feature = "data")]
716    pub(crate) fn forget_data_sync_offer_windows(&mut self, peer: &str) {
717        self.last_data_sync_offer.retain(|key, _| {
718            key != peer
719                && !key
720                    .split_once(GROUP_OFFER_KEY_SEP)
721                    .is_some_and(|(member, _)| member == peer)
722        });
723    }
724
725    /// Whether the reconciliation sweep keyed by `key` is outside its
726    /// suppression window, stamping it when it is.
727    ///
728    /// Stamped here rather than after the send for the reason
729    /// [`Self::offer_versions_over`] gives: a read that fails will still
730    /// fail a moment later, and retrying it at discovery speed is the
731    /// traffic the window exists to prevent.
732    fn data_sync_offer_due(&mut self, key: &str) -> bool {
733        if let Some(last) = self.last_data_sync_offer.get(key) {
734            if Instant::now().duration_since(*last) < DATA_SYNC_OFFER_INTERVAL {
735                return false;
736            }
737        }
738        self.last_data_sync_offer
739            .insert(key.to_string(), Instant::now());
740        true
741    }
742
743    /// Offer versions of every group space this device shares with `peer`.
744    ///
745    /// Wired to the same rediscovery seam as [`Self::kick_data_sync`], and
746    /// needed because a group space has no 1:1 trigger to ride: two members
747    /// may never establish a session with each other, so without this the
748    /// only thing that ever reconciles them is a fresh local commit.
749    ///
750    /// Which groups this device is in comes from MLS, not from the roster
751    /// cache alone. The cache is filled on demand and is empty after a
752    /// restart, so reading only it would make this sweep silently do nothing
753    /// on a cold process, which is the launch where it matters most: two
754    /// members that drifted apart and are not editing have no other trigger
755    /// left.
756    #[cfg_attr(not(feature = "data"), allow(dead_code))]
757    pub(crate) fn kick_shared_group_data_sync(&mut self, peer: &str, cause: &str) {
758        if !self.config.data.enabled {
759            return;
760        }
761        self.enumerate_group_spaces_once();
762        let shared: Vec<String> = self
763            .group_mesh
764            .members
765            .iter()
766            .filter(|(_, members)| members.iter().any(|m| m == peer))
767            .map(|(group_id, _)| group_id.clone())
768            .collect();
769        for group_id in shared {
770            self.kick_group_data_sync(&group_id, peer, cause);
771        }
772    }
773
774    /// Fill the roster cache from MLS once per session, so
775    /// [`Self::kick_shared_group_data_sync`] can see a group that nothing has
776    /// touched yet.
777    ///
778    /// Costs one key listing, plus a group-state read for each group not
779    /// already cached — the same read the next send to that group would have
780    /// paid. Marked done even when a group read fails: the failure is per
781    /// group and retrying the whole enumeration on every discovery is the
782    /// traffic the offer window exists to prevent. An MLS that is not ready
783    /// yet is not a failure of this kind and leaves the flag alone, so the
784    /// next discovery tries again.
785    fn enumerate_group_spaces_once(&mut self) {
786        if self.group_spaces_enumerated {
787            return;
788        }
789        let Ok(groups) = self.list_groups() else {
790            return;
791        };
792        self.group_spaces_enumerated = true;
793        for group_id in groups {
794            if self.group_mesh.members.contains_key(&group_id) {
795                continue;
796            }
797            if let Err(err) = self.refresh_group_members(&group_id) {
798                debug!(group_id = %group_id, error = %err, "Group roster unreadable while enumerating group spaces");
799            }
800        }
801    }
802
803    /// Offer our versions because a change happened that no frame carried,
804    /// without waiting for the rate limit.
805    ///
806    /// `origin` names the peer a change arrived from, so one applied from
807    /// the very peer this space replicates with is not announced back.
808    ///
809    /// The limiter damps discovery flapping, where the offer it suppresses
810    /// is a sweep the next trigger repeats anyway. Nothing repeats this one.
811    /// A change too large to inline left no trace on the wire at all, so
812    /// until somebody asks, both sides hold documents they believe agree;
813    /// on a link that never drops there is no next trigger to ask.
814    pub(crate) fn nudge_data_sync(&mut self, space: &str, origin: Option<&str>, cause: &str) {
815        match self.space_channel(space, origin) {
816            Some(SyncChannel::Peer) => self.offer_versions(space, cause),
817            // The roster-wide leg, and the one place a group offer is not
818            // addressed to one member. What went unsent was a change every
819            // member needs, and there is no single member to ask: each of
820            // them answers with what they are missing, and a member that is
821            // already current answers with nothing.
822            Some(channel @ SyncChannel::GroupBroadcast) => {
823                self.offer_versions_over(space, &channel, cause)
824            }
825            Some(SyncChannel::GroupDirected(_)) | None => {}
826        }
827    }
828
829    /// How a space replicates, or `None` when it does not.
830    ///
831    /// The single place the three kinds of space are told apart. A space is
832    /// a group space when MLS says this device holds a group by that name,
833    /// a 1:1 space when the name is a peer that advertised replication, and
834    /// local-only otherwise — and "local-only" is the honest answer for a
835    /// space whose peer never advertised, whose group is gone, or whose
836    /// members are not all group-capable.
837    ///
838    /// `origin`, when set, names the peer a change was just applied from,
839    /// and suppresses announcing that change back to them.
840    fn space_channel(&mut self, space: &str, origin: Option<&str>) -> Option<SyncChannel> {
841        if let Some(members) = self.group_space_roster(space) {
842            // A change that arrived over the group reached every member at
843            // once, because that is what one group ciphertext does. Pushing
844            // it on would have every member push every change to every
845            // other member: N times the frames for nothing, and worse as
846            // the group grows. Anti-entropy still closes real gaps.
847            if origin.is_some() {
848                return None;
849            }
850            if self.group_data_sync_active(&members) {
851                return Some(SyncChannel::GroupBroadcast);
852            }
853            self.probe_group_data_capabilities(space, &members);
854            return None;
855        }
856        if origin == Some(space) || !self.data_sync_active(space) {
857            return None;
858        }
859        Some(SyncChannel::Peer)
860    }
861
862    /// Send our version of every document in a space to the peer that names
863    /// it, and start the window that suppresses the next sweep.
864    fn offer_versions(&mut self, peer: &str, cause: &str) {
865        // Stamped even on the unlimited path so an offer sent right now
866        // still suppresses the next sweep: the sweep would re-send what
867        // this frame already carried.
868        self.last_data_sync_offer
869            .insert(peer.to_string(), Instant::now());
870        self.offer_versions_over(peer, &SyncChannel::Peer, cause);
871    }
872
873    /// [`Self::offer_versions`] over an explicit channel, which is what a
874    /// group space needs: the offer is addressed to one member, not
875    /// broadcast to the roster.
876    fn offer_versions_over(&mut self, space: &str, channel: &SyncChannel, cause: &str) {
877        let peer = space;
878        let docs = match self.data_sync_versions(peer) {
879            Ok(docs) => docs,
880            Err(err) => {
881                // A whole space that cannot enumerate its documents stops
882                // replicating with this peer until the storage recovers, and
883                // nothing else reports it. The rate limit above is stamped
884                // before this read precisely so warning here cannot become
885                // discovery-speed noise.
886                warn!(peer = %peer, cause, error = %err, "Could not read document versions");
887                return;
888            }
889        };
890        let gone = self.tombstones_for(peer, channel);
891        let want = self.interest_for(peer, channel);
892        debug!(space = %peer, cause, docs = docs.len(), gone = gone.len(), "Offering document versions");
893        self.send_version_frames(
894            peer,
895            channel,
896            OfferContents { docs, gone, want },
897            false,
898            false,
899        );
900    }
901
902    /// The removals to carry toward one channel.
903    ///
904    /// Empty toward a peer that has not advertised them, which is not a
905    /// downgrade but the whole compatibility story: such a peer ignores the
906    /// field, keeps its copy and re-offers it forever, and this device
907    /// refuses each offer against its own floor. Leaving the field out
908    /// keeps that exchange as small as it can be.
909    ///
910    /// A group is different, and is not gated here. One ciphertext reaches
911    /// the whole roster, so there is no per-member choice to make, and a
912    /// member that does not know the field ignores it exactly as a 1:1 peer
913    /// would. The roster-wide gate that decides whether the frame may be
914    /// sent at all is the one on entry 2, which is enforced where the frame
915    /// is sealed.
916    fn tombstones_for(&mut self, space: &str, channel: &SyncChannel) -> BTreeMap<String, String> {
917        if matches!(channel, SyncChannel::Peer) && !self.data_tombstones_active(space) {
918            return BTreeMap::new();
919        }
920        match self.data_sync_tombstones(space) {
921            Ok(gone) => gone,
922            Err(err) => {
923                warn!(space, error = %err, "Could not read this space's removals");
924                BTreeMap::new()
925            }
926        }
927    }
928
929    /// The interest to declare toward one channel.
930    ///
931    /// `None` means everything and is what an unnarrowed space sends, so a
932    /// space nobody has scoped produces the frame it always did. Gated on
933    /// the peer's entry 5 for the same reason removals are gated on entry
934    /// 4: a peer that cannot read it would be sent a field it drops, and
935    /// this device refuses what it does not want on arrival either way.
936    fn interest_for(&mut self, space: &str, channel: &SyncChannel) -> Option<Vec<String>> {
937        if matches!(channel, SyncChannel::Peer) && !self.data_interest_active(space) {
938            return None;
939        }
940        self.data_interest(space).cloned()
941    }
942
943    /// Send one version frame per batch of documents.
944    ///
945    /// `force_partial` marks the frames as carrying less than everything we
946    /// hold even when they fit in one, which is what a question about
947    /// particular documents needs: without it the peer reads the names it
948    /// does not see as documents we have never seen, and answers a question
949    /// about one document with the whole space.
950    fn send_version_frames(
951        &mut self,
952        space: &str,
953        channel: &SyncChannel,
954        offer: OfferContents,
955        reply: bool,
956        force_partial: bool,
957    ) {
958        let OfferContents { docs, gone, want } = offer;
959        // A space with no documents still says so on the offering leg: that
960        // is what tells a peer holding documents to send them to a replica
961        // that has never seen any.
962        if docs.is_empty() && gone.is_empty() && reply {
963            return;
964        }
965        // Removals share the per-frame budget with versions, because they
966        // cost the receiver the same thing: one name it has to store
967        // something about. Counting only the versions would let a space
968        // whose documents were all removed build one frame of a thousand
969        // entries, which is the case most likely to produce a lot of them
970        // at once.
971        let mut entries: Vec<OfferEntry> = Vec::new();
972        let mut removals = gone;
973        for (doc, version) in docs {
974            let floor = removals.remove(&doc);
975            entries.push((doc, Some(version), floor));
976        }
977        for (doc, floor) in removals {
978            entries.push((doc, None, Some(floor)));
979        }
980        entries.sort_by(|left, right| left.0.cmp(&right.0));
981
982        let batches = offer_batches(&entries);
983        // Every frame of a split offer is partial, including the last: the
984        // inference that needs the complete list cannot be drawn from any
985        // one of them, and there is nowhere to accumulate them that a
986        // restart would not have to reconcile.
987        let partial = force_partial || batches.len() > 1;
988        let bodies: Vec<SyncBody> = batches
989            .into_iter()
990            .map(|batch| {
991                let mut batch_docs = BTreeMap::new();
992                let mut batch_gone = BTreeMap::new();
993                for (doc, version, floor) in batch {
994                    if let Some(version) = version {
995                        batch_docs.insert(doc.clone(), version.clone());
996                    }
997                    if let Some(floor) = floor {
998                        batch_gone.insert(doc.clone(), floor.clone());
999                    }
1000                }
1001                SyncBody::Versions {
1002                    reply,
1003                    partial,
1004                    docs: batch_docs,
1005                    gone: batch_gone,
1006                    want: want.clone(),
1007                }
1008            })
1009            .collect();
1010        for body in bodies {
1011            self.send_sync_frame(space, channel, &body);
1012        }
1013    }
1014
1015    /// Ask the peer for what we are missing in one document, without
1016    /// starting an exchange.
1017    ///
1018    /// Both flags are load-bearing. `reply` collects the catch-up while
1019    /// suppressing the counter-offer, so a request cannot start a chain of
1020    /// its own; `partial` keeps the peer from reading one name as the
1021    /// complete list and answering with every other document in the space.
1022    fn request_doc_catch_up(&mut self, space: &str, channel: &SyncChannel, doc: &str) {
1023        let version = match self.data_doc_version(space, doc) {
1024            Ok(version) => version,
1025            Err(err) => {
1026                warn!(space, doc, error = %err, "Could not read our version to ask for the gap");
1027                return;
1028            }
1029        };
1030        // No interest declared on a question about one document: the peer
1031        // answers with that document and draws no inference from the rest of
1032        // a list this frame does not carry.
1033        self.send_version_frames(
1034            space,
1035            channel,
1036            OfferContents {
1037                docs: BTreeMap::from([(doc.to_string(), version)]),
1038                ..OfferContents::default()
1039            },
1040            true,
1041            true,
1042        );
1043    }
1044
1045    /// Seal one frame and put it on the ladder.
1046    ///
1047    /// The channel decides what "seal" means: a 1:1 space seals to its
1048    /// peer, a group space seals once under the group key. Everything
1049    /// above this function is written in terms of a space and a channel and
1050    /// does not know which of the two it is driving, which is what keeps
1051    /// one implementation of the exchange, the catch-up ladder and the
1052    /// import containment serving both.
1053    fn send_sync_frame(&mut self, space: &str, channel: &SyncChannel, body: &SyncBody) {
1054        let plaintext = match serde_json::to_string(body) {
1055            Ok(json) => format!(
1056                "{}{{\"v\":{},{}",
1057                internal_prefixes::DATA_V1,
1058                // The frame schema is the 1:1 version in both cases. The
1059                // group entry advertises that a peer *intercepts* these
1060                // frames inside a group ciphertext; it does not describe a
1061                // second frame shape, and minting one would mean two
1062                // parsers for identical bodies.
1063                DATA_SYNC_V1,
1064                // The body serializes as an object; splice the version in as
1065                // its first field rather than nesting, so a future version
1066                // can be read off a frame whose body shape it cannot parse.
1067                &json[1..]
1068            ),
1069            Err(err) => {
1070                warn!(space, error = %err, "Failed to encode sync frame");
1071                return;
1072            }
1073        };
1074
1075        match channel {
1076            SyncChannel::Peer => self.send_sync_frame_to_peer(space, plaintext),
1077            SyncChannel::GroupBroadcast => {
1078                if let Err(err) = self.send_group_internal_frame(space, None, &plaintext) {
1079                    debug!(space, error = %err, "Group replication frame not sent");
1080                }
1081            }
1082            SyncChannel::GroupDirected(member) => {
1083                let member = member.clone();
1084                if let Err(err) = self.send_group_internal_frame(space, Some(&member), &plaintext) {
1085                    debug!(space, error = %err, "Directed replication frame not sent");
1086                }
1087            }
1088        }
1089    }
1090
1091    /// The 1:1 leg: seal to the peer the space is named after.
1092    ///
1093    /// Deliberately the strict encryptor: a sync frame never initiates
1094    /// session establishment. Documents converge when the peers next talk,
1095    /// and provoking a handshake for an offer nobody asked for would make
1096    /// every reconnect noisier than the messaging it rides on.
1097    fn send_sync_frame_to_peer(&mut self, peer: &str, plaintext: String) {
1098        let encrypted = match self.encrypt_content_for_recipient_strict(peer, &plaintext) {
1099            Ok(content) => content,
1100            Err(err) => {
1101                // No session yet: nothing is lost. The version exchange runs
1102                // again on the next confirm, which is exactly the event that
1103                // would make this succeed.
1104                debug!(peer = %peer, error = %err, "Sync frame skipped: no session");
1105                return;
1106            }
1107        };
1108
1109        let message = match self.create_message(peer, encrypted, None, None) {
1110            Ok(message) => message,
1111            Err(err) => {
1112                warn!(peer = %peer, error = %err, "Failed to build sync frame");
1113                return;
1114            }
1115        };
1116
1117        if self.deduplicator.is_duplicate(&message.id) {
1118            return;
1119        }
1120        self.deduplicator.mark_seen_local(message.id.clone());
1121
1122        // Tier 2: keep the plaintext so a resend after a re-key seals against
1123        // the peer's current epoch instead of replaying ciphertext they can
1124        // no longer open. The analogous session-confirm sender does not do
1125        // this and spends its whole retry budget on dead bytes; there is no
1126        // reason to inherit that. Staged after the duplicate check so the
1127        // entry belongs to a frame that is actually on the ladder.
1128        let recipient = message.recipient.as_str().to_string();
1129        self.stage_outbox_reseal(
1130            &message.id,
1131            &recipient,
1132            crate::protocol::types::OutboxReseal {
1133                content: plaintext,
1134                priority: message.priority,
1135                reply_to_msg: None,
1136                forwarded_from: None,
1137                content_type: message.content_type,
1138                media_metadata: None,
1139                rich: None,
1140                seal_mark: None,
1141            },
1142        );
1143
1144        let previous_transport = self.transport_manager.current_transport();
1145        match self.transport_manager.send(&message) {
1146            Ok(()) => {
1147                let current = self.transport_manager.current_transport();
1148                let _ = self.handle_send_success(&message, current);
1149            }
1150            Err(err) => {
1151                let current = self.transport_manager.current_transport();
1152                let _ = self.handle_send_failure(&message, current.or(previous_transport));
1153                debug!(peer = %peer, error = %err, "Sync frame deferred to the outbox");
1154            }
1155        }
1156    }
1157
1158    /// Push a freshly committed change to the peer this space replicates
1159    /// with.
1160    ///
1161    /// `origin` names the peer a change arrived from, when it did. Echoing a
1162    /// change back to whoever sent it is harmless — the merge absorbs it —
1163    /// but on a mesh it is a frame nobody needed, and a commit produced by
1164    /// applying a remote delta exports that delta again.
1165    pub(crate) fn push_data_delta(
1166        &mut self,
1167        space: &str,
1168        doc: &str,
1169        blob: &[u8],
1170        origin: Option<&str>,
1171    ) {
1172        let Some(channel) = self.space_channel(space, origin) else {
1173            return;
1174        };
1175        // Only a change made here: one applied from a peer is theirs to
1176        // settle, and settling it from here would be the echo `origin`
1177        // exists to stop. Armed before the size check so a change that went
1178        // out as a nudge is settled too; that nudge is one frame and can be
1179        // lost like any other.
1180        if origin.is_none() {
1181            self.arm_data_sync_settle(space, doc);
1182        }
1183        if blob.len() > MAX_SYNC_BLOB_BYTES {
1184            // Too big to inline, so the catch-up ladder has to fetch it: it
1185            // can answer with a compacted snapshot instead of raw history.
1186            // Offering now rather than waiting is what makes that happen. On
1187            // a link that never drops, "the peer's next version offer" is
1188            // not a time, and until it arrives both replicas believe they
1189            // agree.
1190            debug!(
1191                space,
1192                doc,
1193                bytes = blob.len(),
1194                "Change too large to push inline; offering versions instead"
1195            );
1196            self.nudge_data_sync(space, origin, "oversized_delta");
1197            return;
1198        }
1199        self.send_sync_frame(
1200            space,
1201            &channel,
1202            &SyncBody::Delta {
1203                doc: doc.to_string(),
1204                blob: BASE64.encode(blob),
1205            },
1206        );
1207    }
1208
1209    /// Start, or push out, the settle offer for `doc` in `space`.
1210    ///
1211    /// Keyed by space, not by document, so a burst across several documents
1212    /// is still one offer naming all of them. See [`DATA_SYNC_SETTLE_DELAY`].
1213    fn arm_data_sync_settle(&mut self, space: &str, doc: &str) {
1214        let due = Instant::now() + DATA_SYNC_SETTLE_DELAY;
1215        let (at, docs) = self
1216            .data_sync_settle
1217            .entry(space.to_string())
1218            .or_insert_with(|| (due, BTreeSet::new()));
1219        *at = due;
1220        docs.insert(doc.to_string());
1221    }
1222
1223    /// Send every settle offer whose space has been quiet for
1224    /// [`DATA_SYNC_SETTLE_DELAY`]. Driven by [`OfflineProtocol::process`],
1225    /// the binding's periodic tick, like every other timer in the core.
1226    ///
1227    /// The offer names only the documents committed since the last one and
1228    /// is marked partial, so the receiver draws no "never seen it" inference
1229    /// from the rest of the space. It is an ordinary offer and not a reply,
1230    /// because the side that is behind is the receiver, and only its
1231    /// counter-offer carries the version a catch-up is computed from. That
1232    /// counter-offer is a reply and provokes catch-up only, so the exchange
1233    /// is the same terminating 1.5 round trips every other offer starts, and
1234    /// nothing in it re-arms this: only a local commit does, and an import
1235    /// is never one.
1236    ///
1237    /// The offer is small; what it provokes is not. A receiver answers an
1238    /// offer that is not a reply with its complete version list for the
1239    /// space, not just the documents named, so one settle costs a whole-space
1240    /// counter-offer from the 1:1 peer, or from every member of a group, each
1241    /// an addressed frame spending a ratchet generation. That is why it is
1242    /// debounced per space rather than sent per commit. Scoping the
1243    /// counter-offer to a partial offer's names would cut it, and is a
1244    /// protocol change with its own termination argument.
1245    ///
1246    /// Neither consults nor stamps the 30-second offer window. Consulting it
1247    /// would swallow exactly the offers this exists for: the sweep that set
1248    /// the window most likely fired when the peer came into range, seconds
1249    /// before the burst. Stamping it would let an offer about a few documents
1250    /// suppress a sweep of the whole space. It needs no window of its own
1251    /// either: an entry is created only by a local commit and removed when
1252    /// it fires, so this sends at most one offer per space per quiet period,
1253    /// never more often than once per [`DATA_SYNC_SETTLE_DELAY`], and never
1254    /// without a commit behind it.
1255    ///
1256    /// In a group the offer goes to the whole roster rather than addressed to
1257    /// each member. The delta it follows was roster-wide, so any member may
1258    /// have missed it and there is no one member to ask; one roster-wide
1259    /// encryption costs the sender one ratchet generation every member
1260    /// observes, and resets the addressed-frame budget rather than spending
1261    /// it, where one addressed copy per member would spend a generation per
1262    /// member that the others never see. The channel is re-derived here, so
1263    /// a group whose replication gate has closed since the commit is sent
1264    /// nothing, exactly as the commit's own push would have been.
1265    pub(crate) fn send_due_data_settle_offers(&mut self, now: Instant) {
1266        let due: Vec<String> = self
1267            .data_sync_settle
1268            .iter()
1269            .filter(|(_, (at, _))| *at <= now)
1270            .map(|(space, _)| space.clone())
1271            .collect();
1272        for space in due {
1273            let Some((_, docs)) = self.data_sync_settle.remove(&space) else {
1274                continue;
1275            };
1276            let Some(channel) = self.space_channel(&space, None) else {
1277                continue;
1278            };
1279            let mut versions = BTreeMap::new();
1280            for doc in docs {
1281                // A document removed since its commit has a floor, which the
1282                // next sweep carries. Reading its version would be refused
1283                // (a removed name does not open), so skip it here rather
1284                // than log that refusal as a failure.
1285                if !self.data_holds_doc(&space, &doc) {
1286                    continue;
1287                }
1288                match self.data_doc_version(&space, &doc) {
1289                    Ok(version) => {
1290                        versions.insert(doc, version);
1291                    }
1292                    Err(err) => {
1293                        warn!(space, doc, error = %err, "Could not read a version to settle");
1294                    }
1295                }
1296            }
1297            if versions.is_empty() {
1298                continue;
1299            }
1300            debug!(
1301                space,
1302                cause = "settle",
1303                docs = versions.len(),
1304                "Local commits went quiet; offering their versions"
1305            );
1306            self.send_version_frames(
1307                &space,
1308                &channel,
1309                OfferContents {
1310                    docs: versions,
1311                    ..OfferContents::default()
1312                },
1313                false,
1314                true,
1315            );
1316        }
1317    }
1318
1319    // ---- inbound ----------------------------------------------------
1320
1321    /// Handle a `__DATA_V1__` frame that arrived sealed from `sender`.
1322    ///
1323    /// Every outcome is terminal. A frame is never deferred for a
1324    /// data-layer reason: deferral means "this same ciphertext will succeed
1325    /// once the session is ready" and nothing else, so using it for a
1326    /// corrupt blob or a disabled layer would spend the sender's whole retry
1327    /// budget on a frame that cannot ever be accepted.
1328    pub(crate) fn handle_data_sync_frame(&mut self, sender: &str, body: &str) {
1329        // The space is the sender. Never a field on the frame: a peer that
1330        // could name the space could write into a document it replicates
1331        // with somebody else.
1332        self.handle_data_sync_frame_over(sender, &SyncChannel::Peer, sender, body);
1333    }
1334
1335    /// A `__DATA_V1__` frame that arrived inside a *group* ciphertext.
1336    ///
1337    /// The space is the group, and the same structural argument holds as
1338    /// for 1:1: the group is not named by the frame, it is the group whose
1339    /// key opened the ciphertext. A member of one group cannot reach
1340    /// another group's documents without being a member of that group too,
1341    /// and the decrypt already proved membership and authenticated the
1342    /// sender.
1343    ///
1344    /// Answers go back to `sender` alone rather than to the roster. Every
1345    /// other member either has what was asked for or will ask for it
1346    /// themselves.
1347    #[cfg_attr(not(feature = "data"), allow(dead_code))]
1348    pub(crate) fn handle_group_data_sync_frame(
1349        &mut self,
1350        group_id: &str,
1351        sender: &str,
1352        body: &str,
1353    ) {
1354        self.handle_data_sync_frame_over(
1355            group_id,
1356            &SyncChannel::GroupDirected(sender.to_string()),
1357            sender,
1358            body,
1359        );
1360    }
1361
1362    fn handle_data_sync_frame_over(
1363        &mut self,
1364        space: &str,
1365        channel: &SyncChannel,
1366        sender: &str,
1367        body: &str,
1368    ) {
1369        if !self.config.data.enabled {
1370            // Parsing is never capability-gated, but applying is: the layer
1371            // is off, so the frame is dropped. Nothing is lost permanently —
1372            // whatever it carried is still on the sender, and the version
1373            // exchange after the layer comes back on asks for it again.
1374            debug!(peer = %sender, "Sync frame dropped: the data layer is off");
1375            return;
1376        }
1377
1378        // The version is read before the body so a future frame shape is
1379        // declined on its version rather than failing to parse. Reversing
1380        // these two would make every future format look like corruption.
1381        let value: serde_json::Value = match serde_json::from_str(body) {
1382            Ok(value) => value,
1383            Err(err) => {
1384                warn!(peer = %sender, error = %err, "Malformed sync frame");
1385                return;
1386            }
1387        };
1388        match value.get("v").and_then(serde_json::Value::as_u64) {
1389            Some(v) if v == DATA_SYNC_V1 as u64 => {}
1390            other => {
1391                debug!(peer = %sender, version = ?other, "Sync frame from an unsupported version");
1392                return;
1393            }
1394        }
1395        let frame: SyncBody = match serde_json::from_value(value) {
1396            Ok(frame) => frame,
1397            Err(err) => {
1398                warn!(peer = %sender, error = %err, "Unreadable sync frame body");
1399                return;
1400            }
1401        };
1402
1403        let space = space.to_string();
1404        let outcome = match frame {
1405            SyncBody::Versions {
1406                reply,
1407                partial,
1408                docs,
1409                gone,
1410                want,
1411            } => self.answer_version_offer(
1412                &space,
1413                channel,
1414                reply,
1415                partial,
1416                OfferContents { docs, gone, want },
1417            ),
1418            SyncBody::Delta { doc, blob } => {
1419                self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Delta)
1420            }
1421            SyncBody::Snapshot { doc, blob } => {
1422                self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Snapshot)
1423            }
1424            SyncBody::NeedSnapshot { doc } => self.answer_snapshot_request(&space, channel, &doc),
1425            SyncBody::NeedBlob { hash, to } => {
1426                self.answer_blob_request(&space, channel, &hash, to.as_deref())
1427            }
1428            SyncBody::BlobGone { hash } => self.report_blob_gone(&space, sender, &hash),
1429            // The group road only. A 1:1 fetch is answered over the media
1430            // path, which has its own reassembly, its own resource caps and
1431            // its own failure report; admitting a chunk here as well would
1432            // give one outcome two roads to arrive by, differing in every
1433            // bound they answer to and alike in nothing but the hash check
1434            // at the end.
1435            SyncBody::Chunk { .. } if matches!(channel, SyncChannel::Peer) => {
1436                warn!(
1437                    peer = %sender,
1438                    "A blob chunk arrived on a 1:1 session, where bytes ride the media path"
1439                );
1440                Ok(())
1441            }
1442            SyncBody::Chunk { hash, i, n, blob } => {
1443                self.accept_blob_chunk(&space, sender, &hash, i, n, &blob)
1444            }
1445        };
1446        if let Err(err) = outcome {
1447            warn!(peer = %sender, error = %err, "Sync frame could not be handled");
1448        }
1449    }
1450
1451    /// Answer a peer's version offer with what they are missing.
1452    fn answer_version_offer(
1453        &mut self,
1454        space: &str,
1455        channel: &SyncChannel,
1456        reply: bool,
1457        partial: bool,
1458        offer: OfferContents,
1459    ) -> Result<()> {
1460        let OfferContents {
1461            docs: theirs,
1462            gone,
1463            want,
1464        } = offer;
1465        // What the asker wants back. Over-long lists are truncated rather
1466        // than refused: the patterns are a request, and answering the first
1467        // few is closer to what was asked for than answering none. The bound
1468        // is what stops a peer making every document in the space cost a
1469        // walk of a list it chose the length of.
1470        let wanted = want.map(|mut patterns| {
1471            if patterns.len() > MAX_INTEREST_PATTERNS_PER_FRAME {
1472                warn!(
1473                    space,
1474                    patterns = patterns.len(),
1475                    "A version offer declares more interest patterns than are read"
1476                );
1477                patterns.truncate(MAX_INTEREST_PATTERNS_PER_FRAME);
1478            }
1479            patterns
1480        });
1481        let asked_for = |doc: &str| match &wanted {
1482            None => true,
1483            Some(patterns) => patterns
1484                .iter()
1485                .any(|pattern| super::data::interest_matches(pattern, doc)),
1486        };
1487        // Removals first, before anything is created or asked for. A name
1488        // the peer has removed must not be created from this same frame and
1489        // then deleted again, and a version below a floor must not be
1490        // answered with a question about content the floor already covers.
1491        for (doc, encoded) in &gone {
1492            if offline_protocol_data::validate_name(doc).is_err() {
1493                warn!(space, doc, "A removal names an invalid document");
1494                continue;
1495            }
1496            // Bound the decode by the floor budget, the way a blob is bound
1497            // by the frame budget: base64 grows by a third, so the encoded
1498            // length refuses an oversized floor before allocating for it.
1499            if encoded.len() > MAX_REMOVAL_FLOOR_BYTES * 4 / 3 + 4 {
1500                warn!(space, doc, "Oversized removal floor in a sync frame");
1501                continue;
1502            }
1503            // A removal costs a stored record for a name this device may
1504            // never have held, so it answers to the same ceiling every
1505            // other name a peer introduces does. `continue` rather than
1506            // `break`: a name already known here always admits, and a
1507            // removal for a held document sorted after a refused fresh name
1508            // is exactly the removal a full space still has to apply.
1509            if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1510                warn!(
1511                    space,
1512                    doc,
1513                    cap = MAX_DOCS_PER_SPACE,
1514                    "Space is at its document ceiling; ignoring a removal for a new name"
1515                );
1516                continue;
1517            }
1518            let floor = match BASE64.decode(encoded) {
1519                Ok(bytes) => VersionToken::from_bytes(bytes),
1520                Err(err) => {
1521                    warn!(space, doc, error = %err, "Undecodable removal floor in a sync frame");
1522                    continue;
1523                }
1524            };
1525            match self.data_apply_tombstone(space, doc, &floor) {
1526                Ok(outcome) => {
1527                    debug!(space, doc, ?outcome, "Applied a removal from a peer");
1528                }
1529                Err(err) => {
1530                    warn!(space, doc, error = %err, "Could not apply a removal from a peer");
1531                }
1532            }
1533        }
1534
1535        let mut ours = self.data_sync_versions(space).unwrap_or_default();
1536
1537        // Documents they have and we do not. A created document is empty,
1538        // and the only thing that ever fills it is this device asking for
1539        // its contents, so what is created here has to be asked about
1540        // before this frame is done with: the peer has already told us
1541        // everything it intends to and will not volunteer it again.
1542        let mut created = BTreeMap::new();
1543        for (doc, theirs_encoded) in &theirs {
1544            if ours.contains_key(doc) || offline_protocol_data::validate_name(doc).is_err() {
1545                continue;
1546            }
1547            // A version a removal already covers is not a document this
1548            // device has never seen: it is the one it removed. Creating it
1549            // would undo the removal, and asking for it would ask for
1550            // exactly the content the removal deleted.
1551            //
1552            // Only ever asked of a name this device does not hold, which is
1553            // what this loop is. Asked of one it does hold, the answer would
1554            // be wrong in the one case it matters: every version covers the
1555            // empty one, so a replica that re-created the name and has
1556            // nothing in it yet would read as holding removed content, and
1557            // the document it is waiting for would never be sent.
1558            if self.version_is_removed_content(space, doc, theirs_encoded) {
1559                debug!(space, doc, "Ignoring an offer of content a removal covers");
1560                continue;
1561            }
1562            // Our own interest, not theirs: what they hold is their
1563            // business, and what this device stores is ours. This is the
1564            // half that holds against a peer which never reads the field.
1565            if !self.data_wants(space, doc) {
1566                debug!(
1567                    space,
1568                    doc, "Not creating a document outside this space's interest"
1569                );
1570                continue;
1571            }
1572            // The same door a blob for an unknown document goes through,
1573            // and the same ceiling. Counting the versions read above
1574            // instead would undercount by every document whose version
1575            // could not be read, which is how a space gets past its cap.
1576            if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1577                warn!(
1578                    space,
1579                    cap = MAX_DOCS_PER_SPACE,
1580                    "Space is at its document ceiling; ignoring the rest of the offer"
1581                );
1582                break;
1583            }
1584            if self.data_create_doc(space, doc).is_err() {
1585                continue;
1586            }
1587            // Folded into `ours` rather than re-read from the whole space
1588            // below: one version read per frame instead of two, and the
1589            // answer then describes exactly the documents this leg judged.
1590            match self.data_doc_version(space, doc) {
1591                Ok(version) => {
1592                    ours.insert(doc.clone(), version.clone());
1593                    created.insert(doc.clone(), version);
1594                }
1595                Err(err) => {
1596                    warn!(space, doc, error = %err, "Created a document from an offer but cannot read its version");
1597                }
1598            }
1599        }
1600
1601        for (doc, ours_encoded) in &ours {
1602            // Their interest, not ours: this loop decides what to *send*.
1603            // Scoping by what they asked for is safe where scoping by the
1604            // names this frame happened to carry is not, because a document
1605            // they want and have never named is still in our list and still
1606            // matches, so the absence inference below still reaches it.
1607            if !asked_for(doc) {
1608                continue;
1609            }
1610            let Some(theirs_encoded) = theirs.get(doc) else {
1611                // They did not name this document. On a complete offer that
1612                // means they have never seen it and the whole document is
1613                // the answer. On a partial one it means nothing: the name
1614                // may be in a frame that has not arrived, or in no frame at
1615                // all because the offer was about a single document.
1616                if !partial {
1617                    self.offer_catch_up(space, channel, doc, None);
1618                }
1619                continue;
1620            };
1621            if theirs_encoded == ours_encoded {
1622                continue;
1623            }
1624            match BASE64.decode(theirs_encoded) {
1625                Ok(token) => {
1626                    self.offer_catch_up(space, channel, doc, Some(VersionToken::from_bytes(token)))
1627                }
1628                Err(err) => {
1629                    warn!(space, doc, error = %err, "Undecodable version token in a sync frame");
1630                }
1631            }
1632        }
1633
1634        // One answer, and the exchange is over. Answering an answer is how a
1635        // pair of replicas talk to each other forever.
1636        //
1637        // Either way the documents created above are asked about, which is
1638        // the invariant to keep: the counter-offer names them when there is
1639        // one, and when there is not they are asked for on their own. An
1640        // answering leg that skipped the question would leave a document
1641        // created from it empty for as long as the link stays up, because
1642        // the peer has said everything it had to say and nothing else here
1643        // ever asks.
1644        if !reply {
1645            let our_gone = self.tombstones_for(space, channel);
1646            let our_want = self.interest_for(space, channel);
1647            // The counter-offer carries the complete list, interest or no
1648            // interest. Scoping it would save a name per unwanted document
1649            // and buy a familiar hazard: a reply sweep narrowed on the
1650            // answering side is how a document the peer has never seen gets
1651            // dropped with no symptom, and the fact that this particular
1652            // narrowing is provably safe is not worth a rule the next reader
1653            // has to re-derive. What the interest saves is the *document*,
1654            // which the catch-up loop above already declined to send.
1655            //
1656            // Removals are not scoped either, for a reason that is not about
1657            // caution: a peer that narrowed its interest keeps what it
1658            // already held, so a removal for a document it no longer wants
1659            // is exactly the one it still needs.
1660            self.send_version_frames(
1661                space,
1662                channel,
1663                OfferContents {
1664                    docs: ours,
1665                    gone: our_gone,
1666                    want: our_want,
1667                },
1668                true,
1669                false,
1670            );
1671        } else {
1672            self.send_version_frames(
1673                space,
1674                channel,
1675                OfferContents {
1676                    docs: created,
1677                    ..OfferContents::default()
1678                },
1679                true,
1680                true,
1681            );
1682        }
1683        Ok(())
1684    }
1685
1686    /// Whether an offered version names content a removal here already
1687    /// covered.
1688    fn version_is_removed_content(&mut self, space: &str, doc: &str, encoded: &str) -> bool {
1689        match BASE64.decode(encoded) {
1690            Ok(bytes) => {
1691                self.data_version_below_floor(space, doc, &VersionToken::from_bytes(bytes))
1692            }
1693            // Undecodable is not "removed"; the arms below report it where
1694            // the decode is actually needed.
1695            Err(_) => false,
1696        }
1697    }
1698
1699    /// Send `doc` to the peer in whatever form can carry it.
1700    ///
1701    /// The ladder, cheapest first: the changes since their version, a whole
1702    /// document, and then nothing. "Nothing" is a real rung and it is
1703    /// reported rather than retried: a document too large for every inline
1704    /// form needs the media path, which the attachment stage brings, and a
1705    /// silent stall would be indistinguishable from a peer with no changes.
1706    fn offer_catch_up(
1707        &mut self,
1708        space: &str,
1709        channel: &SyncChannel,
1710        doc: &str,
1711        theirs: Option<VersionToken>,
1712    ) {
1713        if let Some(token) = theirs {
1714            match self.data_catch_up(space, doc, &token) {
1715                Ok(CatchUp::UpToDate) => return,
1716                Ok(CatchUp::Updates(bytes)) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
1717                    self.send_sync_frame(
1718                        space,
1719                        channel,
1720                        &SyncBody::Delta {
1721                            doc: doc.to_string(),
1722                            blob: BASE64.encode(bytes),
1723                        },
1724                    );
1725                    return;
1726                }
1727                // Either too large to inline or a gap no run of changes can
1728                // express. Both are answered by the snapshot below.
1729                Ok(_) => {}
1730                Err(err) => {
1731                    warn!(space, doc, error = %err, "Could not compute catch-up");
1732                    return;
1733                }
1734            }
1735        }
1736
1737        self.send_snapshot(space, channel, doc);
1738    }
1739
1740    /// Send a whole document, the top rung and the answer to every refusal
1741    /// below it.
1742    ///
1743    /// Terminal by construction: a snapshot provokes no answer of its own,
1744    /// which is what lets the refusals underneath it ask for one freely.
1745    fn send_snapshot(&mut self, space: &str, channel: &SyncChannel, doc: &str) {
1746        match self.data_export_snapshot(space, doc) {
1747            Ok(bytes) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
1748                self.send_sync_frame(
1749                    space,
1750                    channel,
1751                    &SyncBody::Snapshot {
1752                        doc: doc.to_string(),
1753                        blob: BASE64.encode(bytes),
1754                    },
1755                );
1756            }
1757            // Too large for any frame. The rung above every frame is the
1758            // media path, which is where a document this size goes.
1759            Ok(bytes) => self.carry_snapshot_over_media(space, channel, doc, bytes),
1760            Err(err) => {
1761                warn!(space, doc, error = %err, "Could not export document for catch-up");
1762            }
1763        }
1764    }
1765
1766    /// Send a document the media path carries, because no frame can.
1767    ///
1768    /// The top rung of the catch-up ladder and the last one. A document that
1769    /// cannot go this way either is reported rather than retried: nothing
1770    /// above this exists to try, and the two replicas stay apart until
1771    /// somebody makes the document smaller.
1772    ///
1773    /// Only ever 1:1, and it stayed that way when attachment bytes stopped
1774    /// being. The media path is a transfer to a confirmed session and two
1775    /// members of a group need not have one with each other, so a group
1776    /// space that reaches this rung reports instead. Carrying it as frames
1777    /// the way a blob now travels would need a rule this layer does not
1778    /// have: a blob is asked for, so a chunk of one is admitted against an
1779    /// outstanding request, while a snapshot is unsolicited by design and
1780    /// frames carrying one would be admitted on the sender's say-so alone.
1781    fn carry_snapshot_over_media(
1782        &mut self,
1783        space: &str,
1784        channel: &SyncChannel,
1785        doc: &str,
1786        bytes: Vec<u8>,
1787    ) {
1788        let size = bytes.len();
1789        let report = |protocol: &mut Self, reason: &str| {
1790            warn!(
1791                space,
1792                doc,
1793                bytes = size,
1794                reason,
1795                "Document cannot be replicated"
1796            );
1797            if let Ok(state) = crate::protocol::lock_shared_state(&protocol.shared_state) {
1798                state.emit_event(Event::DataDocUnsyncable {
1799                    space_id: space.to_string(),
1800                    doc_id: doc.to_string(),
1801                    bytes: size as u64,
1802                    reason: reason.to_string(),
1803                });
1804            }
1805        };
1806
1807        if !matches!(channel, SyncChannel::Peer) {
1808            report(self, "group_space_has_no_media_path");
1809            return;
1810        }
1811        if size > MAX_MEDIA_SNAPSHOT_BYTES {
1812            report(self, "over_record_ceiling");
1813            return;
1814        }
1815        if !self.data_media_active(space) {
1816            report(self, "peer_cannot_carry_snapshots");
1817            return;
1818        }
1819
1820        // Not a dead end and not reported as one: the document is already on
1821        // its way. This rung is re-entered on its own while a carriage runs,
1822        // because the peer stays stale for the whole of it and says so on
1823        // every exchange.
1824        let purpose = DataPurpose::Snapshot {
1825            doc: doc.to_string(),
1826        };
1827        if self.data_media_transfer_in_flight(space, &purpose) {
1828            debug!(
1829                space,
1830                doc, "This document is already crossing to the peer; not starting a second copy"
1831            );
1832            return;
1833        }
1834
1835        debug!(
1836            space,
1837            doc,
1838            bytes = size,
1839            "Carrying a snapshot over the media path"
1840        );
1841        if let Err(err) = self.send_media_inner(
1842            space.to_string(),
1843            bytes,
1844            doc.to_string(),
1845            ContentType::File,
1846            MediaSendOptions::default(),
1847            Some(purpose),
1848        ) {
1849            // Transient by nature: no confirmed session yet, the per-peer
1850            // transfer slots are full, or the protocol is not running.
1851            // Reported at debug and left for anti-entropy, which asks again
1852            // on the next exchange. Raising it as unsyncable would tell an
1853            // application a document is permanently stuck when it is merely
1854            // busy.
1855            debug!(space, doc, error = %err, "Snapshot carriage deferred");
1856        }
1857    }
1858
1859    /// Answer a peer that cannot apply what we sent it.
1860    ///
1861    /// Only a document already held is served. A request naming an unknown
1862    /// one would otherwise create it, which is a way to spend this device's
1863    /// storage that does not even require a blob.
1864    fn answer_snapshot_request(
1865        &mut self,
1866        space: &str,
1867        channel: &SyncChannel,
1868        doc: &str,
1869    ) -> Result<()> {
1870        if offline_protocol_data::validate_name(doc).is_err() {
1871            warn!(space, doc, "Snapshot request names an invalid document");
1872            return Ok(());
1873        }
1874        if !self.data_holds_doc(space, doc) {
1875            debug!(space, doc, "Snapshot request for a document we do not hold");
1876            return Ok(());
1877        }
1878        debug!(space, doc, "Serving a snapshot the peer asked for");
1879        self.send_snapshot(space, channel, doc);
1880        Ok(())
1881    }
1882
1883    /// Apply a blob from a peer, with the containment the engine needs.
1884    fn accept_remote_blob(
1885        &mut self,
1886        space: &str,
1887        channel: &SyncChannel,
1888        doc: &str,
1889        encoded: &str,
1890        kind: BlobKind,
1891    ) -> Result<()> {
1892        if offline_protocol_data::validate_name(doc).is_err() {
1893            warn!(space, doc, "Sync frame names an invalid document");
1894            return Ok(());
1895        }
1896        // A blob for a document this device does not hold creates one, so
1897        // this path is bounded by the same ceiling the offer loop is.
1898        if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1899            warn!(
1900                space,
1901                doc,
1902                cap = MAX_DOCS_PER_SPACE,
1903                "Space is at its document ceiling; refusing a new one"
1904            );
1905            return Ok(());
1906        }
1907        // Bound the decode by the frame budget: base64 grows by a third, so
1908        // checking the encoded length first refuses an oversized blob before
1909        // allocating for it.
1910        if encoded.len() > MAX_SYNC_BLOB_BYTES * 4 / 3 + 4 {
1911            warn!(space, doc, "Sync frame carries an oversized blob");
1912            return Ok(());
1913        }
1914        let blob = match BASE64.decode(encoded) {
1915            Ok(blob) => blob,
1916            Err(err) => {
1917                warn!(space, doc, error = %err, "Undecodable blob in a sync frame");
1918                return Ok(());
1919            }
1920        };
1921        if blob.len() > MAX_SYNC_BLOB_BYTES {
1922            warn!(
1923                space,
1924                doc,
1925                bytes = blob.len(),
1926                "Sync blob over the frame budget"
1927            );
1928            return Ok(());
1929        }
1930
1931        self.apply_contained_blob(space, channel, doc, blob, kind)
1932    }
1933
1934    /// Apply remote document bytes with the containment the engine needs.
1935    ///
1936    /// Split from [`Self::accept_remote_blob`] so bytes that arrived over
1937    /// the media path go through exactly this, rather than through a second
1938    /// copy of it. The road the bytes travelled says nothing about what they
1939    /// are: both roads carry an import that can still end the process, from
1940    /// a peer who is authenticated and may still be wrong.
1941    ///
1942    /// The frame budget is deliberately not checked here. It bounds what one
1943    /// frame may carry, which is a fact about frames; the media path has its
1944    /// own, larger bound because it has no such limit.
1945    fn apply_contained_blob(
1946        &mut self,
1947        space: &str,
1948        channel: &SyncChannel,
1949        doc: &str,
1950        blob: Vec<u8>,
1951        kind: BlobKind,
1952    ) -> Result<()> {
1953        if offline_protocol_data::validate_name(doc).is_err() {
1954            warn!(space, doc, "A remote blob names an invalid document");
1955            return Ok(());
1956        }
1957        if !self.data_wants(space, doc) {
1958            debug!(
1959                space,
1960                doc, "Refusing a blob for a document outside this space's interest"
1961            );
1962            return Ok(());
1963        }
1964        if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1965            warn!(
1966                space,
1967                doc,
1968                cap = MAX_DOCS_PER_SPACE,
1969                "Space is at its document ceiling; refusing a new one"
1970            );
1971            return Ok(());
1972        }
1973
1974        let digest = blob_digest(&blob);
1975        let mut record = self.load_sync_record(space);
1976        if record.quarantined.contains(&digest) {
1977            // This blob was in flight when a previous run died. It may be a
1978            // coincidence and it may be the reason; either way applying it
1979            // again to find out is a bad trade, because the cost of being
1980            // wrong is the process and the cost of being right is one
1981            // document change that the sender still holds.
1982            warn!(space, doc, "Refusing a quarantined blob");
1983            return Ok(());
1984        }
1985
1986        // A blob for a name with a removal floor is judged against the floor
1987        // before anything acts on it. A blob carrying nothing beyond the
1988        // floor is the content that removal deleted, arriving from a replica
1989        // that has not heard about it yet: importing it would recreate the
1990        // document. Terminal when it refuses, like every other refusal here:
1991        // the sender is told by the `gone` entry in our next offer, not by
1992        // an answer to this.
1993        //
1994        // The judgement is an engine decode, not a header read. The engine
1995        // decodes every change the blob carries into a scratch document to
1996        // find the version they end at, with the same decoder an import
1997        // runs, and ADR 0019 contains that decoder for a reason: a crafted
1998        // blob can end the process inside it, and under `panic = "abort"`
1999        // nothing catches that. So it runs inside its own in-flight marker,
2000        // exactly as the import below does. A blob that ends the process
2001        // here leaves its digest behind, and the sender's retry is refused
2002        // instead of ending the next process too. Outside the marker this
2003        // would be the one engine decode of a peer's bytes with no
2004        // quarantine, on a path a peer steers a blob onto by sending the
2005        // floor first.
2006        //
2007        // Paid only by a name that has a floor: two marker writes on a path
2008        // a space nobody has removed anything from never takes.
2009        if self.data_doc_has_floor(space, doc) {
2010            record.in_flight = Some(digest.clone());
2011            self.persist_sync_record(space, &record);
2012            let below_floor = self.data_blob_below_floor(space, doc, &blob);
2013            record.in_flight = None;
2014            self.persist_sync_record(space, &record);
2015            if below_floor {
2016                debug!(
2017                    space,
2018                    doc, "Refusing a blob that carries only content a removal covers"
2019                );
2020                return Ok(());
2021            }
2022        }
2023
2024        // Past every refusal, the blob carries something the removal never
2025        // saw, which is an edit made concurrently with it or after it. An
2026        // edit beats a removal, so the name comes back here, after the last
2027        // check that can refuse the blob and before the first step that
2028        // needs the name open. Brought back any earlier, a refused blob
2029        // would leave the name live with no document under it. The floor
2030        // stays, so the rest of the removed content is still refused from a
2031        // replica that has not heard about the removal.
2032        if let Err(err) = self.data_revive_removed_doc(space, doc) {
2033            warn!(space, doc, error = %err, "Could not bring back a removed document for a change that outlives its removal");
2034            return Ok(());
2035        }
2036
2037        // Local edits waiting in memory are committed and pushed *before*
2038        // the import, so they leave on their own delta.
2039        //
2040        // A commit exports everything since the last one, regardless of who
2041        // authored it. So a local edit still pending when a remote blob
2042        // applies is folded into the same delta as the imported change, and
2043        // that delta is then suppressed as an echo toward the one peer it
2044        // was owed to. The edit is durable and announced to nobody: the next
2045        // commit exports only what came after it, and on a link that never
2046        // drops there is no reconnect to run an exchange that would notice.
2047        // Draining first costs one extra delta record and keeps the echo
2048        // suppression judging a delta that really is nothing but the echo.
2049        //
2050        // Reported and stepped over rather than propagated. This is our own
2051        // housekeeping, and a document held over its cap fails here on every
2052        // flush while still needing to accept the remote deletions that
2053        // bring it back under: refusing the import for it would close the
2054        // only door out.
2055        //
2056        // Ahead of the marker below rather than inside its window, so the
2057        // window stays the length of one import. A crash during this flush
2058        // would otherwise quarantine a blob the engine never saw.
2059        //
2060        // The two errors say opposite things about what is still pending, so
2061        // they are told apart. `DocTooLarge` is raised after the delta record
2062        // was written, pushed and announced, and only the size verdict that
2063        // follows it failed: nothing is left in the pending set and there is
2064        // nothing to compensate for. Any other error may have left the commit
2065        // un-exported, because the delta-write failure rewinds it back into
2066        // the pending set. The import's own flush would then fold that edit
2067        // into the imported change and suppress the pair toward the one peer
2068        // it was owed to, which is exactly the loss this pre-flush exists to
2069        // prevent.
2070        let mut preflush_left_edits_pending = false;
2071        match self.data_flush(space, doc) {
2072            Ok(()) => {}
2073            Err(Error::DocTooLarge { .. }) => {
2074                debug!(
2075                    space,
2076                    doc,
2077                    "Local edits were flushed and pushed before applying a remote change; the \
2078                     document is over its cap and refuses further growth"
2079                );
2080            }
2081            Err(err) => {
2082                warn!(
2083                    space,
2084                    doc,
2085                    error = %err,
2086                    "Could not flush local edits before applying a remote change; they may \
2087                     still be pending"
2088                );
2089                preflush_left_edits_pending = true;
2090            }
2091        }
2092
2093        // Written *before* the engine is called. That ordering is the whole
2094        // mechanism: a blob that ends the process leaves its digest behind,
2095        // so the sender's next retry is refused instead of ending the next
2096        // process too.
2097        record.in_flight = Some(digest.clone());
2098        self.persist_sync_record(space, &record);
2099
2100        let outcome = self.data_apply_remote(space, doc, &blob);
2101
2102        record.in_flight = None;
2103        self.persist_sync_record(space, &record);
2104
2105        // Read before the match below consumes the outcome.
2106        let applied = matches!(outcome, Ok(RemoteImport::Applied));
2107
2108        match outcome {
2109            Ok(RemoteImport::Applied) => {
2110                debug!(space, doc, "Applied a remote change");
2111            }
2112            Ok(RemoteImport::AlreadyHave) => {}
2113            Ok(RemoteImport::Parked) => match kind {
2114                // The change is held behind a predecessor that has not
2115                // arrived. Asking for the gap now is what makes the parked
2116                // change durable: it lives only in the engine's memory, so a
2117                // restart before the predecessor lands would lose it, and
2118                // the version exchange is what brings both back. Scoped to
2119                // this document, because this document is what is missing
2120                // something.
2121                BlobKind::Delta => {
2122                    debug!(space, doc, "Remote change parked; asking for the gap");
2123                    self.request_doc_catch_up(space, channel, doc);
2124                }
2125                // A snapshot is the top of the ladder. Asking again returns
2126                // the same bytes, so this is reported and left, rather than
2127                // retried until one side gives up or the battery does.
2128                BlobKind::Snapshot => {
2129                    warn!(
2130                        space,
2131                        doc,
2132                        "A snapshot from the peer did not apply; these replicas cannot be \
2133                         reconciled inline"
2134                    );
2135                }
2136            },
2137            Ok(RemoteImport::RefusedTrimmedHistory) => match kind {
2138                // Nothing the peer can compute from our version closes this
2139                // gap: they would recompute the same refused delta, and
2140                // answering with a version offer is how that becomes an
2141                // exchange neither side can end. Name what would work.
2142                BlobKind::Delta => {
2143                    debug!(space, doc, "Remote change needs a snapshot; asking for one");
2144                    self.send_sync_frame(
2145                        space,
2146                        channel,
2147                        &SyncBody::NeedSnapshot {
2148                            doc: doc.to_string(),
2149                        },
2150                    );
2151                }
2152                // The end of the line, and the one outcome this layer
2153                // cannot recover from. The peer forked below a point this
2154                // replica compacted away, so the ancestors their changes
2155                // need were deleted here rather than left out of the
2156                // message: no frame carries them back, including the whole
2157                // document, which is what just arrived. Reported plainly
2158                // rather than retried, because a retry returns these same
2159                // bytes and refusing them is the only thing keeping the
2160                // process alive.
2161                BlobKind::Snapshot => {
2162                    warn!(
2163                        space,
2164                        doc,
2165                        "A snapshot from the peer cannot merge into this replica; the two \
2166                         have diverged past what replication can reconcile"
2167                    );
2168                }
2169            },
2170            Err(err) => match kind {
2171                BlobKind::Delta => {
2172                    warn!(space, doc, error = %err, "Remote change refused");
2173                }
2174                // The top of the ladder, so there is nothing above it to
2175                // ask for. Unreconcilable divergence is refused before it
2176                // gets here rather than failing here, which leaves this for
2177                // bytes that are simply bad.
2178                BlobKind::Snapshot => {
2179                    warn!(space, doc, error = %err, "A snapshot from the peer was unreadable");
2180                }
2181            },
2182        }
2183
2184        // A pre-flush that failed followed by an import that applied is the
2185        // one combination where a local edit can have been folded into the
2186        // imported change and then suppressed toward the peer it was owed
2187        // to. Nothing on the wire carries it and no trigger is left to
2188        // notice, so the gap is announced and the peer asks for what it is
2189        // missing. The alternative, sending the fold on with the suppression
2190        // lifted, would hand the peer its own change back.
2191        //
2192        // Gated on `Applied` for the reason that also makes it terminate.
2193        // The fold needs the import's own flush to have succeeded, and a
2194        // storage failure that is still failing fails that one too, which
2195        // makes the import answer `Err`. So every nudge costs a fresh
2196        // transient failure that recovered inside one frame, and the offers
2197        // cannot sustain each other. `Applied` and not "anything but `Err`"
2198        // for the same reason: the outcomes that did not apply left the edit
2199        // pending, where the next pre-flush still owes it to the peer.
2200        //
2201        // This is why a document over its cap must answer `Applied` rather
2202        // than `DocTooLarge`, and does: its flush wrote the fold and then
2203        // refused further growth, so the edit is stranded exactly as here,
2204        // on the documents whose only pending edit is the deletion that
2205        // brings them back under.
2206        //
2207        // One residual is left uncompensated on purpose. A `persist_space`
2208        // failure after a flush that succeeded also strands a fold, and is
2209        // indistinguishable here from a flush that failed. Offering on every
2210        // `Err` would cover it and break the argument above: a store that
2211        // keeps failing would then emit an offer per frame, and those do
2212        // sustain each other. The narrower gap is the better trade.
2213        //
2214        // `origin` is `None`, not the space: what may be stranded here is
2215        // this device's own edit, so naming the space would suppress the
2216        // announcement toward the only peer that could act on it.
2217        if preflush_left_edits_pending && applied {
2218            self.nudge_data_sync(space, None, "preflush_failure");
2219        }
2220
2221        Ok(())
2222    }
2223
2224    // ---- attachments -------------------------------------------------
2225
2226    /// Key of a pending fetch: the space asked in, then the blob wanted.
2227    ///
2228    /// Composed rather than keyed by hash alone because the same bytes may
2229    /// be referenced from two spaces, and an answer from one peer must not
2230    /// close a question put to another.
2231    fn attachment_fetch_key(space: &str, hash: &str) -> String {
2232        format!("{space}{GROUP_OFFER_KEY_SEP}{hash}")
2233    }
2234
2235    /// The SHA-256 of `bytes`, in the spelling an attachment reference uses.
2236    ///
2237    /// Exposed because an application writing a reference needs the address
2238    /// of its own bytes, and computing it anywhere else risks a second
2239    /// spelling of the same hash.
2240    pub fn data_attachment_hash(bytes: &[u8]) -> String {
2241        hex_encode(&Sha256::digest(bytes))
2242    }
2243
2244    /// Ask the peer a 1:1 space is named after for the bytes behind a
2245    /// reference.
2246    ///
2247    /// Pull rather than push, and the reason is bandwidth somebody else
2248    /// pays for: a space may hold references to more bytes than a phone
2249    /// wants over Bluetooth, so the decision to spend that belongs to the
2250    /// application, taken per blob, at the moment somebody opens one.
2251    ///
2252    /// The answer arrives later as [`Event::DataAttachmentReceived`] or
2253    /// [`Event::DataAttachmentUnavailable`], never inline.
2254    ///
2255    /// Group spaces are refused in this version. A blob travels the media
2256    /// path, which is a 1:1 transfer to a confirmed session, and two members
2257    /// of a group need not have one with each other.
2258    pub fn data_fetch_attachment(&mut self, space: &str, hash: &str) -> Result<()> {
2259        if !self.config.data.enabled {
2260            return Err(Error::DataDisabled);
2261        }
2262        Self::validate_attachment_hash(hash)?;
2263        if self.group_space_roster(space).is_some() {
2264            return Err(Error::InvalidArgument(format!(
2265                "space {space} is a group: a reference names bytes and not who holds them, so \
2266                 ask a member with data_fetch_attachment_from"
2267            )));
2268        }
2269        if !self.data_media_active(space) {
2270            return Err(Error::InvalidArgument(format!(
2271                "peer {space} did not advertise attachment carriage"
2272            )));
2273        }
2274
2275        let key = Self::attachment_fetch_key(space, hash);
2276        self.expire_attachment_fetches();
2277        // The requester's own bound, which the spec makes a MUST and which
2278        // the holder's window cannot substitute for: an application retrying
2279        // a spinner on a timer would otherwise seal and send one frame per
2280        // retry, and the holder answering none of them is the case that
2281        // provokes the retries.
2282        if let Some(pending) = self.pending_attachment_fetches.get(&key) {
2283            if Instant::now().duration_since(pending.last_seen) < DATA_SYNC_OFFER_INTERVAL {
2284                debug!(
2285                    space,
2286                    "Blob fetch repeated inside its window; already asked"
2287                );
2288                return Ok(());
2289            }
2290        }
2291        if self.pending_attachment_fetches.len() >= MAX_PENDING_ATTACHMENT_FETCHES
2292            && !self.pending_attachment_fetches.contains_key(&key)
2293        {
2294            // Oldest by last sign of life rather than by age, which is what
2295            // keeps a fetch whose bytes are actively arriving out of this:
2296            // every chunk of an answer refreshes its entry, so a live
2297            // carriage sits among the newest and is never the one displaced.
2298            if let Some(oldest) = self
2299                .pending_attachment_fetches
2300                .iter()
2301                .min_by_key(|(_, pending)| pending.last_seen)
2302                .map(|(key, _)| key.clone())
2303            {
2304                let evicted = self.pending_attachment_fetches.remove(&oldest);
2305                // Reported, exactly as an expiry is. Eviction reaches the
2306                // same end state by a different road: the fetch is over and
2307                // no bytes will ever be admitted for it, so an application
2308                // told nothing is left showing the spinner that the whole
2309                // refusal mechanism exists to end.
2310                Self::report_fetch_ended(
2311                    &self.shared_state,
2312                    &oldest,
2313                    evicted
2314                        .as_ref()
2315                        .and_then(|pending| pending.holder.as_deref()),
2316                    "evicted",
2317                );
2318            }
2319        }
2320        self.pending_attachment_fetches
2321            .insert(key, PendingFetch::to_peer());
2322
2323        self.send_sync_frame(
2324            space,
2325            &SyncChannel::Peer,
2326            &SyncBody::NeedBlob {
2327                hash: hash.to_string(),
2328                to: None,
2329            },
2330        );
2331        Ok(())
2332    }
2333
2334    /// Send one chunk frame as a holder would, without the hash check
2335    /// `data_provide_attachment` makes on the way out.
2336    ///
2337    /// Test-only, and it exists because the receive-side hash check has no
2338    /// other way to be reached: the send surface refuses to carry bytes that
2339    /// do not hash to the address they are sent under, so a lying holder
2340    /// cannot be staged through it.
2341    #[cfg(test)]
2342    pub(crate) fn send_chunk_for_test(
2343        &mut self,
2344        space: &str,
2345        channel: &SyncChannel,
2346        hash: &str,
2347        index: u32,
2348        total: u32,
2349        blob: &str,
2350    ) {
2351        self.send_sync_frame(
2352            space,
2353            channel,
2354            &SyncBody::Chunk {
2355                hash: hash.to_string(),
2356                i: index,
2357                n: total,
2358                blob: blob.to_string(),
2359            },
2360        );
2361    }
2362
2363    /// Take one piece of a blob a member is sending.
2364    ///
2365    /// Admitted only against a fetch this device started, and only from the
2366    /// member it was put to. Both halves matter: the first is what stops a
2367    /// member spending this device's memory on bytes nobody asked for, and
2368    /// the second is what stops a different member answering a question that
2369    /// was not put to them. Neither is a substitute for the hash check at
2370    /// the end, which is the only thing that says what the bytes *are*.
2371    ///
2372    /// Provokes no answer in any outcome, which is what keeps `need_blob` a
2373    /// single-hop question.
2374    fn accept_blob_chunk(
2375        &mut self,
2376        space: &str,
2377        sender: &str,
2378        hash: &str,
2379        index: u32,
2380        total: u32,
2381        encoded: &str,
2382    ) -> Result<()> {
2383        if Self::validate_attachment_hash(hash).is_err() {
2384            warn!(space, "A blob chunk names something that is not a hash");
2385            return Ok(());
2386        }
2387        // The receiver's own bound, not a restatement of the sender's. A
2388        // holder that ignores the cap would otherwise spend this device's
2389        // memory up to whatever it felt like declaring.
2390        if total == 0 || total > MAX_GROUP_BLOB_CHUNKS || index >= total {
2391            warn!(
2392                space,
2393                index, total, "A blob chunk declares a shape this layer does not carry"
2394            );
2395            return Ok(());
2396        }
2397        let key = Self::attachment_fetch_key(space, hash);
2398        let Some(pending) = self.pending_attachment_fetches.get_mut(&key) else {
2399            debug!(space, "Discarding a blob chunk nothing asked for");
2400            return Ok(());
2401        };
2402        if !pending.is_answered_by(space, sender) {
2403            warn!(
2404                space,
2405                sender, "Discarding a blob chunk from a member this fetch was not put to"
2406            );
2407            return Ok(());
2408        }
2409        let bytes = match BASE64.decode(encoded) {
2410            Ok(bytes) => bytes,
2411            Err(err) => {
2412                warn!(space, error = %err, "Undecodable blob chunk");
2413                return Ok(());
2414            }
2415        };
2416        if bytes.len() > MAX_SYNC_BLOB_BYTES {
2417            warn!(
2418                space,
2419                bytes = bytes.len(),
2420                "A blob chunk is over the frame budget"
2421            );
2422            return Ok(());
2423        }
2424
2425        // Arriving bytes are the sign of life the timeout measures from, and
2426        // the refresh happens before anything below can refuse this chunk:
2427        // a carriage in progress must not expire because one piece of it was
2428        // malformed.
2429        pending.last_seen = Instant::now();
2430        let assembly = pending.assembly.get_or_insert_with(BlobAssembly::default);
2431        if assembly.total == 0 {
2432            assembly.total = total;
2433        } else if assembly.total != total {
2434            // One of the two is lying about the shape. The chunk is refused
2435            // rather than the fetch, because the hash at the end is what
2436            // decides the question either way, and ending the fetch here
2437            // would let one bad frame cancel a carriage that is otherwise
2438            // arriving.
2439            warn!(
2440                space,
2441                declared = assembly.total,
2442                arriving = total,
2443                "A blob chunk contradicts the count its siblings declared"
2444            );
2445            return Ok(());
2446        }
2447        assembly.chunks.insert(index, bytes);
2448        if (assembly.chunks.len() as u32) < assembly.total {
2449            return Ok(());
2450        }
2451
2452        // Complete. Whether it is the *right* blob is a separate question,
2453        // and the only one authentication cannot answer.
2454        let assembled: Vec<u8> = assembly
2455            .chunks
2456            .values()
2457            .flat_map(|chunk| chunk.iter().copied())
2458            .collect();
2459        let asked = self
2460            .pending_attachment_fetches
2461            .remove(&key)
2462            .and_then(|pending| pending.holder);
2463
2464        let actual = Self::data_attachment_hash(&assembled);
2465        if actual != hash {
2466            warn!(
2467                space,
2468                sender, "Assembled blob does not hash to what was asked for"
2469            );
2470            Self::report_fetch_ended(&self.shared_state, &key, asked.as_deref(), "hash_mismatch");
2471            return Ok(());
2472        }
2473        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
2474            state.emit_event(Event::DataAttachmentReceived {
2475                space_id: space.to_string(),
2476                peer_id: sender.to_string(),
2477                hash: hash.to_string(),
2478                data: BASE64.encode(&assembled),
2479            });
2480        }
2481        Ok(())
2482    }
2483
2484    /// Ask one member of a group for the bytes behind a reference.
2485    ///
2486    /// A group needs the member named, where a 1:1 space does not: the
2487    /// reference replicates to everybody and says nothing about who holds
2488    /// the bytes. The SDK does not walk the roster on the application's
2489    /// behalf, because each miss costs the whole silence timeout and a
2490    /// reference nobody holds would spend one per member. The application
2491    /// knows who wrote the reference; that is the member to ask.
2492    ///
2493    /// The answer arrives as frames under the group key rather than as a
2494    /// transfer, so it needs no pairwise session with that member, and it is
2495    /// bounded at 32 pieces of 32 KiB: 1 MiB in all. Anything larger travels
2496    /// over a 1:1 session, and the holder is told so at the call.
2497    pub fn data_fetch_attachment_from(
2498        &mut self,
2499        space: &str,
2500        peer: &str,
2501        hash: &str,
2502    ) -> Result<()> {
2503        if !self.config.data.enabled {
2504            return Err(Error::DataDisabled);
2505        }
2506        Self::validate_attachment_hash(hash)?;
2507        let Some(members) = self.group_space_roster(space) else {
2508            return Err(Error::InvalidArgument(format!(
2509                "space {space} is not a group; use data_fetch_attachment"
2510            )));
2511        };
2512        if !members.iter().any(|member| member == peer) {
2513            return Err(Error::InvalidArgument(format!(
2514                "{peer} is not a member of {space}"
2515            )));
2516        }
2517        // This device is in the roster it just read, so naming itself is a
2518        // question it would put to nobody and then wait out the silence
2519        // timeout for. An application walking a roster to choose a member
2520        // reaches this, and the honest answer is that it has the bytes or
2521        // it does not.
2522        if peer == self.local_id {
2523            return Err(Error::InvalidArgument(
2524                "a group fetch names another member to ask, and a device is not a peer of itself"
2525                    .to_string(),
2526            ));
2527        }
2528        // A question, and the only directed frame this layer puts to a
2529        // member who asked for nothing. Every other one answers something
2530        // that member sent, which is itself proof they intercept these
2531        // frames; this one carries no such proof, so it needs the positive
2532        // knowledge the roster-wide gate requires of every member before a
2533        // frame is broadcast to them. Without it a member on a build that
2534        // does not intercept `__DATA_V1__` is handed the question as chat
2535        // text, which is the single failure that gate exists to refuse.
2536        //
2537        // An inviter attests this for members a device has never dealt with
2538        // directly, which is what keeps the ordinary case working: a group
2539        // is not built out of existing 1:1 contacts.
2540        if !self.data_group_frames_known_received(peer) {
2541            return Err(Error::InvalidArgument(format!(
2542                "nothing is known about whether member {peer} receives replication \
2543                 frames inside a group, and a question they cannot intercept would \
2544                 be shown to them as text"
2545            )));
2546        }
2547        // Refused only on knowledge, never on its absence. Members do not
2548        // exchange key packages with each other, so "we have not heard" is
2549        // the ordinary case and must not read as "they cannot": the
2550        // inviter's attestation fills it in where there is one, and where
2551        // there is not, asking and hearing nothing is the honest outcome
2552        // and the silence timeout reports it.
2553        if self.data_group_blob_known_absent(peer) {
2554            return Err(Error::InvalidArgument(format!(
2555                "member {peer} does not carry attachment bytes inside a group"
2556            )));
2557        }
2558
2559        let key = Self::attachment_fetch_key(space, hash);
2560        self.expire_attachment_fetches();
2561        match self.pending_attachment_fetches.get(&key) {
2562            // The same question asked twice, which is what the window is
2563            // for: a spinner on a timer would otherwise seal and send one
2564            // frame per retry.
2565            Some(pending)
2566                if pending.holder.as_deref() == Some(peer)
2567                    && Instant::now().duration_since(pending.last_seen)
2568                        < DATA_SYNC_OFFER_INTERVAL =>
2569            {
2570                debug!(
2571                    space,
2572                    peer, "Blob fetch repeated inside its window; already asked"
2573                );
2574                return Ok(());
2575            }
2576            // A different member is a different question, and the one an
2577            // application falls back to when the first goes quiet. The
2578            // record is keyed by the space and the blob, neither of which
2579            // changes, so without this a fallback inside the window
2580            // returned `Ok` and asked nobody, and after it silently threw
2581            // away whatever the first member had already sent.
2582            //
2583            // Displacing the first is reported like any other eviction,
2584            // naming the member it was put to, so an application tracking
2585            // a question per member is not left holding one that never
2586            // ends.
2587            Some(pending) if pending.holder.as_deref() != Some(peer) => {
2588                let displaced = self.pending_attachment_fetches.remove(&key);
2589                Self::report_fetch_ended(
2590                    &self.shared_state,
2591                    &key,
2592                    displaced
2593                        .as_ref()
2594                        .and_then(|pending| pending.holder.as_deref()),
2595                    "evicted",
2596                );
2597            }
2598            _ => {}
2599        }
2600        if self.pending_attachment_fetches.len() >= MAX_PENDING_ATTACHMENT_FETCHES
2601            && !self.pending_attachment_fetches.contains_key(&key)
2602        {
2603            if let Some(oldest) = self
2604                .pending_attachment_fetches
2605                .iter()
2606                .min_by_key(|(_, pending)| pending.last_seen)
2607                .map(|(key, _)| key.clone())
2608            {
2609                let evicted = self.pending_attachment_fetches.remove(&oldest);
2610                Self::report_fetch_ended(
2611                    &self.shared_state,
2612                    &oldest,
2613                    evicted
2614                        .as_ref()
2615                        .and_then(|pending| pending.holder.as_deref()),
2616                    "evicted",
2617                );
2618            }
2619        }
2620        self.pending_attachment_fetches
2621            .insert(key, PendingFetch::to_member(peer));
2622
2623        self.send_sync_frame(
2624            space,
2625            &SyncChannel::GroupDirected(peer.to_string()),
2626            &SyncBody::NeedBlob {
2627                hash: hash.to_string(),
2628                to: Some(peer.to_string()),
2629            },
2630        );
2631        Ok(())
2632    }
2633
2634    /// Answer a peer's request with the bytes.
2635    ///
2636    /// The bytes come from the application because this SDK never held them:
2637    /// a blob does not enter a document and does not enter protocol state,
2638    /// so the only copy is wherever the app put it.
2639    ///
2640    /// Refuses bytes that do not hash to `hash`. That check is here rather
2641    /// than only on the receiving side so a mistake is reported to the app
2642    /// that made it, while it still has the file in hand, instead of
2643    /// travelling the whole media path to be refused by somebody else.
2644    pub fn data_provide_attachment(
2645        &mut self,
2646        space: &str,
2647        peer: &str,
2648        hash: &str,
2649        bytes: Vec<u8>,
2650    ) -> Result<()> {
2651        if !self.config.data.enabled {
2652            return Err(Error::DataDisabled);
2653        }
2654        Self::validate_attachment_hash(hash)?;
2655        if bytes.is_empty() {
2656            return Err(Error::InvalidArgument(
2657                "an attachment must have bytes".to_string(),
2658            ));
2659        }
2660        let group = self.group_space_roster(space);
2661        if group.is_none() && space != peer {
2662            return Err(Error::InvalidArgument(format!(
2663                "space {space} is not the 1:1 space with {peer}"
2664            )));
2665        }
2666        if let Some(members) = &group {
2667            if !members.iter().any(|member| member == peer) {
2668                return Err(Error::InvalidArgument(format!(
2669                    "{peer} is not a member of {space}"
2670                )));
2671            }
2672        }
2673        let actual = Self::data_attachment_hash(&bytes);
2674        if actual != hash {
2675            return Err(Error::InvalidArgument(format!(
2676                "these bytes hash to {actual}, not to {hash}"
2677            )));
2678        }
2679
2680        // A group answer leaves as frames rather than as a transfer, and
2681        // leaves all at once: that is what keeps the request a single-hop
2682        // question, and it is why the size is bounded here rather than by
2683        // the transfer layer's own limit.
2684        if group.is_some() {
2685            let chunks = bytes.len().div_ceil(MAX_SYNC_BLOB_BYTES);
2686            if chunks > MAX_GROUP_BLOB_CHUNKS as usize {
2687                return Err(Error::InvalidArgument(format!(
2688                    "an attachment carried inside a group is at most {} bytes, and these are {}: \
2689                     send it over a 1:1 session instead",
2690                    MAX_GROUP_BLOB_CHUNKS as usize * MAX_SYNC_BLOB_BYTES,
2691                    bytes.len()
2692                )));
2693            }
2694            let channel = SyncChannel::GroupDirected(peer.to_string());
2695            for (index, chunk) in bytes.chunks(MAX_SYNC_BLOB_BYTES).enumerate() {
2696                self.send_sync_frame(
2697                    space,
2698                    &channel,
2699                    &SyncBody::Chunk {
2700                        hash: hash.to_string(),
2701                        i: index as u32,
2702                        n: chunks as u32,
2703                        blob: BASE64.encode(chunk),
2704                    },
2705                );
2706            }
2707            return Ok(());
2708        }
2709
2710        // Answering twice is not an error to report back: the app did as it
2711        // was asked, and the bytes are on their way. Idempotent rather than
2712        // refused, because a request repeated after its window reopens is
2713        // ordinary and an app has no way to see that its first answer is
2714        // still in flight.
2715        let purpose = DataPurpose::Attachment {
2716            hash: hash.to_string(),
2717        };
2718        if self.data_media_transfer_in_flight(peer, &purpose) {
2719            debug!(
2720                peer,
2721                "These bytes are already crossing to this peer; not starting a second copy"
2722            );
2723            return Ok(());
2724        }
2725
2726        self.send_media_inner(
2727            peer.to_string(),
2728            bytes,
2729            // The media layer wants a file name and an attachment has none
2730            // that is load-bearing: the display name lives on the reference
2731            // in the document, where every replica can already read it.
2732            hash.to_string(),
2733            ContentType::File,
2734            MediaSendOptions::default(),
2735            Some(purpose),
2736        )?;
2737        Ok(())
2738    }
2739
2740    /// Tell a peer their fetch will not be answered.
2741    ///
2742    /// A first-class answer rather than silence. The asking side cannot
2743    /// otherwise tell a peer that no longer holds the bytes from one that is
2744    /// merely slow, and shows a person a spinner that never resolves.
2745    pub fn data_decline_attachment(&mut self, space: &str, peer: &str, hash: &str) -> Result<()> {
2746        if !self.config.data.enabled {
2747            return Err(Error::DataDisabled);
2748        }
2749        Self::validate_attachment_hash(hash)?;
2750        let group = self.group_space_roster(space);
2751        if group.is_none() && space != peer {
2752            return Err(Error::InvalidArgument(format!(
2753                "space {space} is not the 1:1 space with {peer}"
2754            )));
2755        }
2756        let channel = match &group {
2757            Some(members) => {
2758                if !members.iter().any(|member| member == peer) {
2759                    return Err(Error::InvalidArgument(format!(
2760                        "{peer} is not a member of {space}"
2761                    )));
2762                }
2763                SyncChannel::GroupDirected(peer.to_string())
2764            }
2765            None => {
2766                // Gated like every other frame in this file, and it is the
2767                // one that was not. A refusal toward a peer that never
2768                // advertised the capability is unreadable to them, so the
2769                // caller believes they answered and the asking side keeps
2770                // waiting; toward a blocked peer it is a liveness signal
2771                // blocking exists to withhold.
2772                //
2773                // A group needs no equivalent: the refusal rides the group
2774                // key, which every member can already open.
2775                if !self.data_media_active(peer) {
2776                    return Err(Error::InvalidArgument(format!(
2777                        "peer {peer} did not advertise attachment carriage"
2778                    )));
2779                }
2780                SyncChannel::Peer
2781            }
2782        };
2783        self.send_sync_frame(
2784            space,
2785            &channel,
2786            &SyncBody::BlobGone {
2787                hash: hash.to_string(),
2788            },
2789        );
2790        Ok(())
2791    }
2792
2793    /// Whether a peer's request for one blob should reach the application.
2794    ///
2795    /// Two bounds, because a peer controls both how often it asks and what it
2796    /// asks for. The per-hash window stops a repeat; the per-peer budget
2797    /// stops a flood of distinct hashes, which the window alone cannot see as
2798    /// a flood at all because every hash in it is new.
2799    ///
2800    /// Kept out of the version-offer window map deliberately. Every other key
2801    /// in that map is a peer or a peer-and-group, both bounded by a roster.
2802    /// This one would be bounded by nothing, and that map has no cap and no
2803    /// sweep.
2804    fn blob_request_due(&mut self, peer: &str, hash: &str) -> bool {
2805        let now = Instant::now();
2806        self.blob_request_windows
2807            .retain(|_, seen| now.duration_since(*seen) < DATA_SYNC_OFFER_INTERVAL);
2808
2809        let key = format!("{peer}{GROUP_OFFER_KEY_SEP}{hash}");
2810        if self.blob_request_windows.contains_key(&key) {
2811            debug!(peer, "Blob request repeated inside its window; ignoring");
2812            return false;
2813        }
2814
2815        let live_for_peer = self
2816            .blob_request_windows
2817            .keys()
2818            .filter(|key| {
2819                key.split_once(GROUP_OFFER_KEY_SEP)
2820                    .is_some_and(|(asker, _)| asker == peer)
2821            })
2822            .count();
2823        if live_for_peer >= MAX_BLOB_REQUESTS_PER_WINDOW {
2824            warn!(
2825                peer,
2826                budget = MAX_BLOB_REQUESTS_PER_WINDOW,
2827                "Peer is over its blob-request budget; ignoring"
2828            );
2829            return false;
2830        }
2831
2832        // The global bound, which holds when a peer's budget resets and when
2833        // many peers each stay under theirs.
2834        if self.blob_request_windows.len() >= MAX_BLOB_REQUEST_WINDOWS {
2835            if let Some(oldest) = self
2836                .blob_request_windows
2837                .iter()
2838                .min_by_key(|(_, seen)| **seen)
2839                .map(|(key, _)| key.clone())
2840            {
2841                self.blob_request_windows.remove(&oldest);
2842            }
2843        }
2844        self.blob_request_windows.insert(key, now);
2845        true
2846    }
2847
2848    fn validate_attachment_hash(hash: &str) -> Result<()> {
2849        offline_protocol_data::validate_attachment_hash(hash)
2850            .map_err(|err| Error::InvalidArgument(err.to_string()))
2851    }
2852
2853    /// Whether this device is waiting for the blob behind `hash` from `peer`.
2854    ///
2855    /// Read before a transfer is admitted rather than after it completes.
2856    /// Refusing at the end still refuses, but only once the bytes have been
2857    /// buffered, reassembled and checksummed, which is the storage and the
2858    /// battery the check exists to protect.
2859    pub(crate) fn awaiting_attachment(&self, peer: &str, hash: &str) -> bool {
2860        self.pending_attachment_fetches
2861            .contains_key(&Self::attachment_fetch_key(peer, hash))
2862    }
2863
2864    /// Forget everything remembered about attachments with one peer.
2865    ///
2866    /// Composed through [`Self::attachment_fetch_key`] rather than by
2867    /// re-spelling the separator, which is what the separator constant exists
2868    /// for: a key built one way and matched another is a leak that no test
2869    /// notices until the two spellings diverge.
2870    pub(crate) fn forget_attachment_state(&mut self, peer: &str) {
2871        let prefix = Self::attachment_fetch_key(peer, "");
2872        // Reported, not merely dropped. This is a road that ends a fetch
2873        // without bytes like any other, and it is reached while an
2874        // application is waiting: a peer re-keying without the capability,
2875        // an unblock, a peer forgotten to keep a set bounded. Removing the
2876        // record silently also forecloses the expiry that would have
2877        // reported it later, so the spinner this event exists to end never
2878        // ends at all.
2879        let ended: Vec<(String, Option<String>)> = self
2880            .pending_attachment_fetches
2881            .iter()
2882            // Two roads reach the same peer. On a 1:1 space the peer *is*
2883            // the space, so the key carries it; in a group the space is the
2884            // group and the member asked is inside the record. A question
2885            // put to a peer this device has stopped replicating with cannot
2886            // be answered by either road, so both go, and a group fetch
2887            // left behind would hold its slot until it timed out.
2888            .filter(|(key, pending)| {
2889                key.starts_with(&prefix) || pending.holder.as_deref() == Some(peer)
2890            })
2891            .map(|(key, pending)| (key.clone(), pending.holder.clone()))
2892            .collect();
2893        for (key, holder) in ended {
2894            self.pending_attachment_fetches.remove(&key);
2895            Self::report_fetch_ended(&self.shared_state, &key, holder.as_deref(), "peer_gone");
2896        }
2897        self.blob_request_windows
2898            .retain(|key, _| !key.starts_with(&prefix));
2899    }
2900
2901    /// [`Self::forget_attachment_state`] for every peer at once, for the
2902    /// road that forgets every peer at once.
2903    ///
2904    /// Not a loop over the roster, because the caller has already emptied
2905    /// it: the capability sets are cleared wholesale when the bound on
2906    /// remembered peers is reached, so by then there is no peer left to name
2907    /// and a fetch keyed by one would be swept by nothing.
2908    pub(crate) fn end_every_pending_fetch(&mut self) {
2909        for (key, pending) in std::mem::take(&mut self.pending_attachment_fetches) {
2910            Self::report_fetch_ended(
2911                &self.shared_state,
2912                &key,
2913                pending.holder.as_deref(),
2914                "peer_gone",
2915            );
2916        }
2917    }
2918
2919    /// Report a fetch this device has given up on, keyed the way
2920    /// [`Self::attachment_fetch_key`] composes them.
2921    ///
2922    /// Shared by every road that ends a fetch without bytes, because they all
2923    /// owe the application the same thing and a second spelling of this event
2924    /// is a road that quietly owes it nothing. `reason` is a fixed token this
2925    /// crate chooses, never a peer's words: see the producer rule in
2926    /// `telemetry::scrub_event`.
2927    ///
2928    /// `holder` is the member the question was put to, which every caller
2929    /// reads off the record it is ending. It is carried rather than derived
2930    /// because it cannot be: the key composes the *space*, and in a group
2931    /// the space is not the peer.
2932    fn report_fetch_ended(
2933        shared_state: &std::sync::Arc<std::sync::Mutex<crate::protocol::SharedState>>,
2934        key: &str,
2935        holder: Option<&str>,
2936        reason: &str,
2937    ) {
2938        let Some((space, hash)) = key.split_once(GROUP_OFFER_KEY_SEP) else {
2939            return;
2940        };
2941        debug!(space, reason, "A blob fetch ended with no bytes");
2942        if let Ok(state) = crate::protocol::lock_shared_state(shared_state) {
2943            state.emit_event(Event::DataAttachmentUnavailable {
2944                space_id: space.to_string(),
2945                // The member the question was put to. A 1:1 space IS the
2946                // peer, which is why the key alone composes both there; a
2947                // group space is one scope with many members, so the record
2948                // carries the one asked and this reads it. Falling back to
2949                // the space in a group would hand an application a group id
2950                // where this event's own documentation promises the peer
2951                // that was asked, and every road but the refusal would
2952                // report it.
2953                peer_id: holder.unwrap_or(space).to_string(),
2954                hash: hash.to_string(),
2955                reason: reason.to_string(),
2956            });
2957        }
2958    }
2959
2960    /// Note that the answer to a fetch is on its way.
2961    ///
2962    /// A transfer that is visibly progressing is the answer this device is
2963    /// waiting for, so the question must not expire while it arrives. Without
2964    /// this the record times out mid-carriage and
2965    /// [`Self::accept_data_media_payload`] then drops a blob that fully
2966    /// arrived as one nobody asked for, which no retry can fix: the retry
2967    /// carries the same bytes over the same radio and expires at the same
2968    /// point.
2969    ///
2970    /// Only an attachment has a record to refresh. A snapshot is unsolicited
2971    /// by design and keeps none.
2972    pub(crate) fn refresh_attachment_fetch(&mut self, peer: &str, purpose: &DataPurpose) {
2973        let DataPurpose::Attachment { hash } = purpose else {
2974            return;
2975        };
2976        let key = Self::attachment_fetch_key(peer, hash);
2977        if let Some(seen_at) = self.pending_attachment_fetches.get_mut(&key) {
2978            seen_at.last_seen = Instant::now();
2979        }
2980    }
2981
2982    /// Whether a transfer for exactly this purpose is already on its way to
2983    /// this peer.
2984    ///
2985    /// Both roads onto the media path are re-entered by things that recur on
2986    /// their own: a peer stays stale for the whole multi-minute carriage of a
2987    /// document, so every anti-entropy exchange in that window reaches the
2988    /// top rung again, and an application answering a repeated request has no
2989    /// way to know its first answer is still in flight.
2990    ///
2991    /// Without this check those repeats become second copies of one transfer.
2992    /// A peer may hold only [`MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER`] at
2993    /// once, so the duplicates halve the throughput of the transfer that
2994    /// matters and then fill the slots, and the next thing to fail is the
2995    /// application's own media send, with a limit error whose cause it cannot
2996    /// see: the transfers holding those slots are invisible to it by design.
2997    ///
2998    /// [`MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER`]: crate::constants::MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER
2999    fn data_media_transfer_in_flight(&self, recipient: &str, purpose: &DataPurpose) -> bool {
3000        self.outbound_media_transfers.values().any(|transfer| {
3001            transfer.recipient == recipient && transfer.data_purpose.as_ref() == Some(purpose)
3002        })
3003    }
3004
3005    /// Give up on fetches nobody answered, and say so.
3006    ///
3007    /// The reporting half is the point. A holder is only asked to answer
3008    /// `need_blob`, never required to, so a fetch can go unanswered forever
3009    /// with nothing wrong anywhere. Expiring the entry silently would leave
3010    /// the asking application exactly where the whole refusal mechanism
3011    /// exists to stop it being: showing somebody a spinner that never
3012    /// resolves.
3013    pub(crate) fn expire_attachment_fetches(&mut self) {
3014        let now = Instant::now();
3015        let expired: Vec<(String, Option<String>)> = self
3016            .pending_attachment_fetches
3017            .iter()
3018            .filter(|(_, pending)| {
3019                now.duration_since(pending.last_seen) >= ATTACHMENT_FETCH_TIMEOUT
3020            })
3021            .map(|(key, pending)| (key.clone(), pending.holder.clone()))
3022            .collect();
3023        for (key, holder) in expired {
3024            self.pending_attachment_fetches.remove(&key);
3025            Self::report_fetch_ended(&self.shared_state, &key, holder.as_deref(), "timeout");
3026        }
3027    }
3028
3029    /// A peer wants the bytes behind a reference.
3030    ///
3031    /// Handed straight to the application, because the application is the
3032    /// only party that has them. Rate limited per blob on the window that
3033    /// suppresses version offers: a peer retrying a fetch must not turn into
3034    /// a stream of events, and the answer to the second ask within the
3035    /// window is the same as the answer to the first.
3036    fn answer_blob_request(
3037        &mut self,
3038        space: &str,
3039        channel: &SyncChannel,
3040        hash: &str,
3041        to: Option<&str>,
3042    ) -> Result<()> {
3043        if Self::validate_attachment_hash(hash).is_err() {
3044            warn!(space, "Blob request names something that is not a hash");
3045            return Ok(());
3046        }
3047        // The member that asked, which in a group is not the space. A
3048        // roster-wide request is refused rather than answered: there is
3049        // nobody to send the bytes to, and answering it would mean choosing
3050        // a member on the asker's behalf.
3051        let asker = match channel {
3052            SyncChannel::Peer => space.to_string(),
3053            SyncChannel::GroupDirected(member) => member.clone(),
3054            SyncChannel::GroupBroadcast => {
3055                warn!(
3056                    space,
3057                    "A blob request arrived addressed to the whole roster; ignoring"
3058                );
3059                return Ok(());
3060            }
3061        };
3062        // Inside a group the question names the member it is for, and one
3063        // that names somebody else is not ours to answer. The sender's
3064        // directed frame is promoted to the whole roster when its ratchet
3065        // budget runs out, so it arrives at every member either way; without
3066        // this check every member's application would be asked for bytes
3067        // that only one of them can have accepted.
3068        if !matches!(channel, SyncChannel::Peer) {
3069            match to {
3070                Some(member) if member == self.local_id => {}
3071                Some(_) => {
3072                    debug!(
3073                        space,
3074                        "A blob request names another member; not ours to answer"
3075                    );
3076                    return Ok(());
3077                }
3078                None => {
3079                    warn!(
3080                        space,
3081                        "A blob request inside a group names no member; ignoring"
3082                    );
3083                    return Ok(());
3084                }
3085            }
3086        }
3087        // A request this device could not answer is not put to the
3088        // application. Both answers to `DataAttachmentRequested` refuse
3089        // toward a peer that never advertised carriage: `provide` cannot
3090        // reach the media path and `decline` cannot reach the wire. Emitting
3091        // it anyway hands an app a question with no legal reply, which reads
3092        // to whoever handles it as an SDK that rejects its own events.
3093        //
3094        // Ahead of the rate limiter rather than behind it, so a peer that
3095        // cannot be answered spends no window slot: the budget exists to
3096        // bound work this device would otherwise do, and there is none here.
3097        let answerable = match channel {
3098            SyncChannel::Peer => self.data_media_active(space),
3099            // In a group the question is whether *this* device can answer,
3100            // and it can: the bytes leave as frames under the group key,
3101            // which every member can already open. What the asker speaks is
3102            // their problem, and they only get an answer because they asked.
3103            _ => self.config.data.enabled,
3104        };
3105        if !answerable {
3106            warn!(
3107                space,
3108                "A blob request came from a peer that never advertised carriage; ignoring"
3109            );
3110            return Ok(());
3111        }
3112        // Budgeted against the member that asked rather than the space, or
3113        // one member could spend the whole group's window.
3114        if !self.blob_request_due(&asker, hash) {
3115            return Ok(());
3116        }
3117        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3118            state.emit_event(Event::DataAttachmentRequested {
3119                space_id: space.to_string(),
3120                peer_id: asker,
3121                hash: hash.to_string(),
3122            });
3123        }
3124        Ok(())
3125    }
3126
3127    /// A peer says the bytes are gone. End the fetch.
3128    ///
3129    /// Checked the same way [`Self::answer_blob_request`] is, and for the
3130    /// same reason rather than a hypothetical one: a fetch is 1:1 state, and
3131    /// a frame arriving over a group would compose a key from a group id.
3132    /// Unreachable today because a group id cannot collide with an address,
3133    /// which is exactly the kind of accident a later refactor removes.
3134    fn report_blob_gone(&mut self, space: &str, sender: &str, hash: &str) -> Result<()> {
3135        if Self::validate_attachment_hash(hash).is_err() {
3136            warn!(space, "Blob refusal names something that is not a hash");
3137            return Ok(());
3138        }
3139        let key = Self::attachment_fetch_key(space, hash);
3140        // Only a fetch this device actually made is reported, and only by
3141        // the peer it was put to. Otherwise a peer could emit refusals for
3142        // blobs nobody asked about, and in a group any member could end a
3143        // question that was put to somebody else.
3144        let answers_us = self
3145            .pending_attachment_fetches
3146            .get(&key)
3147            .is_some_and(|pending| pending.is_answered_by(space, sender));
3148        if !answers_us {
3149            debug!(
3150                space,
3151                sender, "A blob refusal answers no question we put to this peer"
3152            );
3153            return Ok(());
3154        }
3155        self.pending_attachment_fetches.remove(&key);
3156        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3157            state.emit_event(Event::DataAttachmentUnavailable {
3158                space_id: space.to_string(),
3159                peer_id: sender.to_string(),
3160                hash: hash.to_string(),
3161                reason: "declined".to_string(),
3162            });
3163        }
3164        Ok(())
3165    }
3166
3167    // ---- what arrives over the media path ------------------------------
3168
3169    /// A data-purposed media transfer completed. Route it by its purpose.
3170    ///
3171    /// The space is the authenticated wire sender, never anything the
3172    /// transfer said about itself, which is the same rule every sync frame
3173    /// follows and for the same reason: a space a peer can name is a space a
3174    /// peer can reach.
3175    ///
3176    /// Gated on the kill switch, which is belt to the storage seam's braces
3177    /// rather than the thing that stops an import: `require_data_storage`
3178    /// already fails closed with `DataDisabled`, so a device with the layer
3179    /// off writes nothing regardless. This makes the refusal local and free,
3180    /// and keeps the rule uniform across both roads into this layer. The
3181    /// admission check in `receive` is the one that earns its keep, because
3182    /// it is the one that refuses before a whole blob is buffered.
3183    pub(crate) fn accept_data_media_payload(
3184        &mut self,
3185        sender: &str,
3186        purpose: &DataPurpose,
3187        bytes: Vec<u8>,
3188    ) {
3189        if !self.config.data.enabled {
3190            debug!(
3191                peer = %sender,
3192                "Document-layer transfer dropped: the data layer is off"
3193            );
3194            return;
3195        }
3196        match purpose {
3197            DataPurpose::Attachment { hash } => {
3198                let key = Self::attachment_fetch_key(sender, hash);
3199                if self.pending_attachment_fetches.remove(&key).is_none() {
3200                    warn!(
3201                        peer = %sender,
3202                        "Attachment bytes arrived for a fetch this device never made; dropping"
3203                    );
3204                    return;
3205                }
3206                // The address is checked against the bytes, not against what
3207                // the sender says about them. This is what makes fetching
3208                // from an authenticated peer safe without trusting that
3209                // peer: the worst a wrong answer achieves is no answer.
3210                let actual = Self::data_attachment_hash(&bytes);
3211                if &actual != hash {
3212                    warn!(
3213                        peer = %sender,
3214                        "Attachment bytes do not hash to what was asked for; dropping"
3215                    );
3216                    if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3217                        state.emit_event(Event::DataAttachmentUnavailable {
3218                            space_id: sender.to_string(),
3219                            peer_id: sender.to_string(),
3220                            hash: hash.clone(),
3221                            reason: "hash_mismatch".to_string(),
3222                        });
3223                    }
3224                    return;
3225                }
3226                if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3227                    state.emit_event(Event::DataAttachmentReceived {
3228                        space_id: sender.to_string(),
3229                        peer_id: sender.to_string(),
3230                        hash: hash.clone(),
3231                        data: BASE64.encode(&bytes),
3232                    });
3233                }
3234            }
3235            DataPurpose::Snapshot { doc } => {
3236                if bytes.len() > MAX_MEDIA_SNAPSHOT_BYTES {
3237                    warn!(
3238                        peer = %sender,
3239                        bytes = bytes.len(),
3240                        "A carried snapshot is larger than a record can hold; refusing"
3241                    );
3242                    return;
3243                }
3244                // Straight into the containment every remote blob goes
3245                // through. Arriving by a longer road changes nothing about
3246                // what these bytes are: an import that can still end the
3247                // process, from a peer who is exactly who they say they are.
3248                let doc = doc.clone();
3249                if let Err(err) = self.apply_contained_blob(
3250                    sender,
3251                    &SyncChannel::Peer,
3252                    &doc,
3253                    bytes,
3254                    BlobKind::Snapshot,
3255                ) {
3256                    warn!(peer = %sender, doc, error = %err, "A carried snapshot was refused");
3257                }
3258            }
3259        }
3260    }
3261
3262    /// A data-purposed media transfer failed. Say so where it can be acted on.
3263    pub(crate) fn report_data_media_failure(
3264        &mut self,
3265        sender: &str,
3266        purpose: &DataPurpose,
3267        reason: &str,
3268    ) {
3269        match purpose {
3270            DataPurpose::Attachment { hash } => {
3271                let key = Self::attachment_fetch_key(sender, hash);
3272                // Only a fetch still outstanding is reported, the same rule
3273                // the completion and refusal roads already follow. A fetch
3274                // that ended while its transfer was still moving (declined
3275                // mid-carriage, evicted, expired) has had its one answer,
3276                // and a second one for the same hash reads to an
3277                // application as a second failure of a thing it is no
3278                // longer waiting for.
3279                if self.pending_attachment_fetches.remove(&key).is_none() {
3280                    debug!(
3281                        peer = %sender,
3282                        reason,
3283                        "A transfer failed for a fetch that has already ended"
3284                    );
3285                    return;
3286                }
3287                if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3288                    state.emit_event(Event::DataAttachmentUnavailable {
3289                        space_id: sender.to_string(),
3290                        peer_id: sender.to_string(),
3291                        hash: hash.clone(),
3292                        reason: reason.to_string(),
3293                    });
3294                }
3295            }
3296            // Nothing to report to an application: a snapshot that did not
3297            // arrive is a gap anti-entropy will find again, and the next
3298            // exchange asks for it afresh.
3299            DataPurpose::Snapshot { doc } => {
3300                debug!(peer = %sender, doc, reason, "A carried snapshot did not arrive");
3301            }
3302        }
3303    }
3304
3305    // ---- the crash record -------------------------------------------
3306
3307    /// Read a space's replication bookkeeping, promoting anything left in
3308    /// flight by a previous run into the quarantine.
3309    fn load_sync_record(&mut self, space: &str) -> SyncRecord {
3310        let Some(storage) = self.data_storage_for_sync() else {
3311            return SyncRecord::default();
3312        };
3313        let mut record =
3314            match self.read_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space) {
3315                Ok(Some(bytes)) => serde_json::from_slice::<SyncRecord>(&bytes).unwrap_or_default(),
3316                _ => SyncRecord::default(),
3317            };
3318        if let Some(digest) = record.in_flight.take() {
3319            warn!(
3320                space,
3321                "A previous run did not survive applying a remote change; quarantining it"
3322            );
3323            if !record.quarantined.contains(&digest) {
3324                record.quarantined.push(digest);
3325                while record.quarantined.len() > MAX_QUARANTINED_BLOBS {
3326                    record.quarantined.remove(0);
3327                }
3328            }
3329            self.persist_sync_record(space, &record);
3330        }
3331        record
3332    }
3333
3334    fn persist_sync_record(&mut self, space: &str, record: &SyncRecord) {
3335        let Some(storage) = self.data_storage_for_sync() else {
3336            return;
3337        };
3338        let bytes = match serde_json::to_vec(record) {
3339            Ok(bytes) => bytes,
3340            Err(err) => {
3341                warn!(space, error = %err, "Could not encode replication bookkeeping");
3342                return;
3343            }
3344        };
3345        if let Err(err) =
3346            self.write_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space, &bytes)
3347        {
3348            // Not fatal, and deliberately not a refusal to apply: failing to
3349            // write the marker costs the crash protection for this one blob,
3350            // while refusing would stop replication on a storage hiccup.
3351            warn!(space, error = %err, "Could not persist replication bookkeeping");
3352        }
3353    }
3354}
3355
3356#[cfg(test)]
3357mod tests {
3358    use super::*;
3359
3360    #[test]
3361    fn a_name_in_both_maps_costs_two_entries_of_the_frame_budget() {
3362        let entry = |n: usize, version: bool, floor: bool| -> OfferEntry {
3363            (
3364                format!("doc{n}"),
3365                version.then(|| "AQID".to_string()),
3366                floor.then(|| "BAUG".to_string()),
3367            )
3368        };
3369
3370        let full: Vec<OfferEntry> = (0..MAX_DOCS_PER_VERSION_FRAME)
3371            .map(|n| entry(n, true, false))
3372            .collect();
3373        assert_eq!(offer_batches(&full).len(), 1);
3374
3375        let one_over: Vec<OfferEntry> = (0..=MAX_DOCS_PER_VERSION_FRAME)
3376            .map(|n| entry(n, true, false))
3377            .collect();
3378        assert_eq!(offer_batches(&one_over).len(), 2);
3379
3380        // Every name came back after a removal, so each costs the frame two
3381        // versions: half the budget in names is the whole budget in bytes.
3382        let dual: Vec<OfferEntry> = (0..=MAX_DOCS_PER_VERSION_FRAME / 2)
3383            .map(|n| entry(n, true, true))
3384            .collect();
3385        let batches = offer_batches(&dual);
3386        assert_eq!(
3387            batches.len(),
3388            2,
3389            "a frame of names in both maps was built to twice the byte budget"
3390        );
3391        assert_eq!(
3392            batches.iter().map(|batch| batch.len()).sum::<usize>(),
3393            dual.len(),
3394            "an entry was dropped or duplicated across the split"
3395        );
3396
3397        assert_eq!(
3398            offer_batches(&[]).len(),
3399            1,
3400            "an empty offer is still one frame: it is what tells a peer to send everything"
3401        );
3402    }
3403
3404    #[test]
3405    fn a_frame_carries_its_version_where_a_future_build_can_find_it() {
3406        // The version has to be readable off a frame whose body this build
3407        // cannot parse, or every future format looks like corruption to
3408        // every older install.
3409        let body = SyncBody::Delta {
3410            doc: "notes".to_string(),
3411            blob: "AAAA".to_string(),
3412        };
3413        let json = serde_json::to_string(&body).unwrap();
3414        let framed = format!("{{\"v\":{},{}", DATA_SYNC_V1, &json[1..]);
3415
3416        let value: serde_json::Value = serde_json::from_str(&framed).unwrap();
3417        assert_eq!(value.get("v").and_then(|v| v.as_u64()), Some(1));
3418        assert_eq!(value.get("k").and_then(|v| v.as_str()), Some("delta"));
3419
3420        let round_trip: SyncBody = serde_json::from_value(value).unwrap();
3421        assert!(matches!(round_trip, SyncBody::Delta { doc, .. } if doc == "notes"));
3422    }
3423
3424    #[test]
3425    fn a_version_frame_round_trips() {
3426        let body = SyncBody::Versions {
3427            reply: true,
3428            partial: true,
3429            docs: BTreeMap::from([("notes".to_string(), "dgA=".to_string())]),
3430            gone: BTreeMap::from([("old".to_string(), "ZwA=".to_string())]),
3431            want: Some(vec!["notes".to_string(), "draft*".to_string()]),
3432        };
3433        let json = serde_json::to_string(&body).unwrap();
3434        let parsed: SyncBody = serde_json::from_str(&json).unwrap();
3435        match parsed {
3436            SyncBody::Versions {
3437                reply,
3438                partial,
3439                docs,
3440                gone,
3441                want,
3442            } => {
3443                assert!(reply);
3444                assert!(partial);
3445                assert_eq!(docs.get("notes").map(String::as_str), Some("dgA="));
3446                assert_eq!(gone.get("old").map(String::as_str), Some("ZwA="));
3447                assert_eq!(
3448                    want.as_deref(),
3449                    Some(["notes".to_string(), "draft*".to_string()].as_slice())
3450                );
3451            }
3452            other => panic!("expected a version frame, got {other:?}"),
3453        }
3454    }
3455
3456    #[test]
3457    fn an_offer_with_no_removals_carries_no_removal_field() {
3458        // Byte-identical to what a build that predates removals sends, which
3459        // is what keeps every frozen vector above valid and every space
3460        // nobody has removed anything from as cheap as it was.
3461        let json = serde_json::to_string(&SyncBody::Versions {
3462            reply: false,
3463            partial: false,
3464            docs: BTreeMap::new(),
3465            gone: BTreeMap::new(),
3466            want: None,
3467        })
3468        .unwrap();
3469        assert_eq!(
3470            json,
3471            r#"{"k":"vv","reply":false,"partial":false,"docs":{}}"#
3472        );
3473    }
3474
3475    #[test]
3476    fn an_offer_that_says_nothing_about_completeness_is_read_as_complete() {
3477        // The two flags default to the shape an ordinary whole-space offer
3478        // has, so a frame that omits them behaves like one. Getting this
3479        // backwards would silence the inference that carries a document a
3480        // peer has never seen.
3481        let parsed: SyncBody = serde_json::from_str(r#"{"k":"vv","docs":{}}"#).unwrap();
3482        match parsed {
3483            SyncBody::Versions {
3484                reply,
3485                partial,
3486                docs,
3487                gone,
3488                want,
3489            } => {
3490                assert!(!reply);
3491                assert!(!partial);
3492                assert!(docs.is_empty());
3493                assert!(
3494                    want.is_none(),
3495                    "an offer from a build that predates interest read as asking for a subset"
3496                );
3497                assert!(
3498                    gone.is_empty(),
3499                    "an offer from a build that predates removals read as removing something"
3500                );
3501            }
3502            other => panic!("expected a version frame, got {other:?}"),
3503        }
3504    }
3505
3506    #[test]
3507    fn a_snapshot_request_round_trips_and_carries_no_bytes() {
3508        // It names a document and nothing else: the request exists because
3509        // the asker cannot express its own gap, so anything it added would
3510        // be the thing that was already refused.
3511        let body = SyncBody::NeedSnapshot {
3512            doc: "notes".to_string(),
3513        };
3514        let json = serde_json::to_string(&body).unwrap();
3515        assert_eq!(json, r#"{"k":"need_snap","doc":"notes"}"#);
3516
3517        let parsed: SyncBody = serde_json::from_str(&json).unwrap();
3518        assert!(matches!(parsed, SyncBody::NeedSnapshot { doc } if doc == "notes"));
3519    }
3520
3521    #[test]
3522    fn digests_separate_blobs_and_repeat_for_the_same_one() {
3523        assert_eq!(blob_digest(b"one"), blob_digest(b"one"));
3524        assert_ne!(blob_digest(b"one"), blob_digest(b"two"));
3525        assert_eq!(blob_digest(b"one").len(), 32);
3526    }
3527}
3528
3529/// The frozen wire vectors for `__DATA_V1__`.
3530///
3531/// The conformance surface a second implementation is written against, and
3532/// the reason this layer is a protocol rather than an SDK feature. The file
3533/// was computed independently of the code it pins, so a mismatch is evidence
3534/// about the code rather than a disagreement between two copies of the same
3535/// mistake.
3536///
3537/// When one of these fails the wire format has changed. Bump the frame
3538/// version and negotiate a new `data_versions` entry. Do NOT edit the
3539/// expected values to make the test pass: every shipped install still speaks
3540/// the old ones, and a silent change here is a silent break there.
3541#[cfg(test)]
3542mod golden_vectors {
3543    use super::*;
3544
3545    const VECTORS: &str = include_str!("../../tests/data/data-sync-v1.vectors.json");
3546
3547    fn vectors() -> serde_json::Value {
3548        serde_json::from_str(VECTORS).expect("the vector file is JSON")
3549    }
3550
3551    /// Frame the way [`OfflineProtocol::send_sync_frame`] does, which is the
3552    /// only place the version is spliced in. Reproduced rather than called
3553    /// because calling it needs a whole protocol, and what is pinned here is
3554    /// the encoding, not the send path.
3555    fn framed(body: &SyncBody) -> String {
3556        let json = serde_json::to_string(body).expect("encode");
3557        format!(
3558            "{}{{\"v\":{},{}",
3559            internal_prefixes::DATA_V1,
3560            DATA_SYNC_V1,
3561            &json[1..]
3562        )
3563    }
3564
3565    fn wire(vectors: &serde_json::Value, name: &str) -> String {
3566        vectors["frames"]
3567            .as_array()
3568            .expect("frames")
3569            .iter()
3570            .find(|frame| frame["name"] == name)
3571            .unwrap_or_else(|| panic!("no vector named {name}"))["wire"]
3572            .as_str()
3573            .expect("wire is a string")
3574            .to_string()
3575    }
3576
3577    #[test]
3578    fn the_vector_file_is_the_size_it_was() {
3579        // A file silently shortened by a bad merge would make every
3580        // assertion below pass by not running.
3581        //
3582        // Every array, not only the frames. The rest are read by `for`
3583        // loops and by index, and a loop over an emptied array is the same
3584        // green test as a loop over a full one. Counting them here is what
3585        // makes "the vectors pass" mean the vectors ran.
3586        let vectors = vectors();
3587        let len = |name: &str| -> usize {
3588            vectors[name]
3589                .as_array()
3590                .unwrap_or_else(|| panic!("{name} must be an array"))
3591                .len()
3592        };
3593        assert_eq!(len("frames"), 12);
3594        assert_eq!(len("parse_defaults"), 3);
3595        assert_eq!(len("attachment_hashes"), 3);
3596        assert_eq!(len("attachment_references"), 2);
3597        assert_eq!(len("data_purposes"), 2);
3598        assert_eq!(len("blob_digests"), 1);
3599        assert_eq!(vectors["version"].as_u64(), Some(DATA_SYNC_V1 as u64));
3600        assert_eq!(vectors["prefix"].as_str(), Some(internal_prefixes::DATA_V1));
3601    }
3602
3603    #[test]
3604    fn every_frame_kind_encodes_to_its_vector() {
3605        let vectors = vectors();
3606
3607        let mut docs = BTreeMap::new();
3608        docs.insert("notes".to_string(), "AQID".to_string());
3609        assert_eq!(
3610            framed(&SyncBody::Versions {
3611                reply: false,
3612                partial: false,
3613                docs,
3614                gone: BTreeMap::new(),
3615                want: None,
3616            }),
3617            wire(&vectors, "version offer, complete, asking")
3618        );
3619
3620        // Inserted out of order on purpose: the map is ordered, so the wire
3621        // does not depend on what order a sender happened to hold them in.
3622        let mut docs = BTreeMap::new();
3623        docs.insert("b".to_string(), "Zg==".to_string());
3624        docs.insert("a".to_string(), String::new());
3625        assert_eq!(
3626            framed(&SyncBody::Versions {
3627                reply: true,
3628                partial: true,
3629                docs,
3630                gone: BTreeMap::new(),
3631                want: None,
3632            }),
3633            wire(&vectors, "version offer, answering leg")
3634        );
3635
3636        assert_eq!(
3637            framed(&SyncBody::Versions {
3638                reply: false,
3639                partial: false,
3640                docs: BTreeMap::new(),
3641                gone: BTreeMap::new(),
3642                want: None,
3643            }),
3644            wire(&vectors, "version offer with no documents")
3645        );
3646
3647        let mut docs = BTreeMap::new();
3648        docs.insert("kept".to_string(), "AQID".to_string());
3649        let mut gone = BTreeMap::new();
3650        gone.insert("removed".to_string(), "BAUG".to_string());
3651        assert_eq!(
3652            framed(&SyncBody::Versions {
3653                reply: false,
3654                partial: false,
3655                docs,
3656                gone,
3657                want: None,
3658            }),
3659            wire(&vectors, "version offer carrying a removal")
3660        );
3661
3662        let mut docs = BTreeMap::new();
3663        docs.insert("notes".to_string(), "AQID".to_string());
3664        assert_eq!(
3665            framed(&SyncBody::Versions {
3666                reply: false,
3667                partial: false,
3668                docs,
3669                gone: BTreeMap::new(),
3670                want: Some(vec!["notes".to_string(), "draft*".to_string()]),
3671            }),
3672            wire(&vectors, "version offer declaring an interest")
3673        );
3674
3675        let blob = BASE64.encode([0u8, 1, 2, 3, 4, 5, 6, 7]);
3676        assert_eq!(
3677            framed(&SyncBody::Delta {
3678                doc: "notes".to_string(),
3679                blob: blob.clone(),
3680            }),
3681            wire(&vectors, "delta")
3682        );
3683        assert_eq!(
3684            framed(&SyncBody::Snapshot {
3685                doc: "notes".to_string(),
3686                blob,
3687            }),
3688            wire(&vectors, "snapshot")
3689        );
3690        assert_eq!(
3691            framed(&SyncBody::NeedSnapshot {
3692                doc: "notes".to_string(),
3693            }),
3694            wire(&vectors, "snapshot request")
3695        );
3696
3697        let hash = vectors["attachment_hashes"][0]["sha256"]
3698            .as_str()
3699            .expect("hash")
3700            .to_string();
3701        assert_eq!(
3702            framed(&SyncBody::NeedBlob {
3703                hash: hash.clone(),
3704                to: None,
3705            }),
3706            wire(&vectors, "blob request")
3707        );
3708        assert_eq!(
3709            framed(&SyncBody::BlobGone { hash: hash.clone() }),
3710            wire(&vectors, "blob refusal")
3711        );
3712
3713        assert_eq!(
3714            framed(&SyncBody::NeedBlob {
3715                hash: hash.clone(),
3716                to: Some("off1qy60a3pu0l9tnthnk08c4w59teq7u6w28gjwg8zk".to_string()),
3717            }),
3718            wire(&vectors, "blob request addressed to one member")
3719        );
3720
3721        assert_eq!(
3722            framed(&SyncBody::Chunk {
3723                hash,
3724                i: 0,
3725                n: 2,
3726                blob: BASE64.encode([0u8, 1, 2, 3]),
3727            }),
3728            wire(&vectors, "blob chunk")
3729        );
3730    }
3731
3732    #[test]
3733    fn every_vector_parses_back_into_the_kind_it_names() {
3734        // The other direction, and the one a second implementation's sender
3735        // is judged by. Encoding-only vectors would let a decoder drift.
3736        let vectors = vectors();
3737        for frame in vectors["frames"].as_array().expect("frames") {
3738            let wire = frame["wire"].as_str().expect("wire");
3739            let body = wire
3740                .strip_prefix(internal_prefixes::DATA_V1)
3741                .expect("every vector carries the marker");
3742            let value: serde_json::Value = serde_json::from_str(body).expect("the body is JSON");
3743            assert_eq!(
3744                value["v"].as_u64(),
3745                Some(DATA_SYNC_V1 as u64),
3746                "{}: version",
3747                frame["name"]
3748            );
3749            assert_eq!(
3750                value["k"].as_str(),
3751                frame["kind"].as_str(),
3752                "{}: kind",
3753                frame["name"]
3754            );
3755            let parsed: SyncBody =
3756                serde_json::from_value(value).expect("the body parses into a frame");
3757            let kind = match parsed {
3758                SyncBody::Versions { .. } => "vv",
3759                SyncBody::Delta { .. } => "delta",
3760                SyncBody::Snapshot { .. } => "snap",
3761                SyncBody::NeedSnapshot { .. } => "need_snap",
3762                SyncBody::NeedBlob { .. } => "blob",
3763                SyncBody::BlobGone { .. } => "gone",
3764                SyncBody::Chunk { .. } => "chunk",
3765            };
3766            let expected = match frame["kind"].as_str().expect("kind") {
3767                "need_blob" => "blob",
3768                "blob_gone" => "gone",
3769                other => other,
3770            };
3771            assert_eq!(kind, expected, "{}: round trip", frame["name"]);
3772        }
3773    }
3774
3775    #[test]
3776    fn absent_flags_parse_as_false() {
3777        let vectors = vectors();
3778        for case in vectors["parse_defaults"]
3779            .as_array()
3780            .expect("parse_defaults")
3781        {
3782            let value: serde_json::Value =
3783                serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
3784            let SyncBody::Versions { reply, partial, .. } =
3785                serde_json::from_value(value).expect("parses")
3786            else {
3787                panic!("{}: expected a version offer", case["name"]);
3788            };
3789            assert_eq!(reply, case["expect"]["reply"].as_bool().expect("reply"));
3790            assert_eq!(
3791                partial,
3792                case["expect"]["partial"].as_bool().expect("partial")
3793            );
3794        }
3795    }
3796
3797    #[test]
3798    fn an_absent_interest_parses_as_asking_for_everything() {
3799        // Absent and empty are opposite answers, and the frozen cases pin
3800        // the one a build that predates the field sends.
3801        let vectors = vectors();
3802        for case in vectors["parse_defaults"]
3803            .as_array()
3804            .expect("parse_defaults")
3805        {
3806            let value: serde_json::Value =
3807                serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
3808            let SyncBody::Versions { want, .. } = serde_json::from_value(value).expect("parses")
3809            else {
3810                panic!("{}: expected a version offer", case["name"]);
3811            };
3812            assert!(
3813                case["expect"]["want"].is_null(),
3814                "{}: this case is about an absent interest",
3815                case["name"]
3816            );
3817            assert!(want.is_none(), "{}", case["name"]);
3818        }
3819    }
3820
3821    #[test]
3822    fn an_empty_interest_is_not_an_absent_one() {
3823        // The distinction the `Option` exists for: collapsing them would
3824        // make "send me nothing" read as "send me everything".
3825        let asks_for_nothing = serde_json::to_string(&SyncBody::Versions {
3826            reply: false,
3827            partial: false,
3828            docs: BTreeMap::new(),
3829            gone: BTreeMap::new(),
3830            want: Some(Vec::new()),
3831        })
3832        .unwrap();
3833        assert!(asks_for_nothing.contains(r#""want":[]"#));
3834
3835        let parsed: SyncBody = serde_json::from_str(&asks_for_nothing).unwrap();
3836        let SyncBody::Versions { want, .. } = parsed else {
3837            panic!("expected a version offer");
3838        };
3839        assert_eq!(want, Some(Vec::new()));
3840    }
3841
3842    #[test]
3843    fn an_absent_removal_map_parses_as_removing_nothing() {
3844        let vectors = vectors();
3845        for case in vectors["parse_defaults"]
3846            .as_array()
3847            .expect("parse_defaults")
3848        {
3849            let value: serde_json::Value =
3850                serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
3851            let SyncBody::Versions { gone, .. } = serde_json::from_value(value).expect("parses")
3852            else {
3853                panic!("{}: expected a version offer", case["name"]);
3854            };
3855            let expected = case["expect"]["gone"].as_object().expect("gone");
3856            assert_eq!(gone.len(), expected.len(), "{}", case["name"]);
3857        }
3858    }
3859
3860    #[test]
3861    fn attachment_hashes_match_their_vectors() {
3862        let vectors = vectors();
3863        for case in vectors["attachment_hashes"]
3864            .as_array()
3865            .expect("attachment_hashes")
3866        {
3867            let bytes = match (case.get("input_utf8"), case.get("input_hex")) {
3868                (Some(text), _) => text.as_str().expect("utf8").as_bytes().to_vec(),
3869                (_, Some(hex)) => {
3870                    let hex = hex.as_str().expect("hex");
3871                    (0..hex.len())
3872                        .step_by(2)
3873                        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
3874                        .collect()
3875                }
3876                _ => panic!("a hash vector needs an input"),
3877            };
3878            assert_eq!(
3879                bytes.len() as u64,
3880                case["size"].as_u64().expect("size"),
3881                "the vector's own size must describe its own input"
3882            );
3883            assert_eq!(
3884                OfflineProtocol::data_attachment_hash(&bytes),
3885                case["sha256"].as_str().expect("sha256").to_string()
3886            );
3887        }
3888    }
3889
3890    #[test]
3891    fn attachment_references_encode_to_their_vectors() {
3892        use offline_protocol_data::DataValue;
3893        let vectors = vectors();
3894        let hash = vectors["attachment_hashes"][0]["sha256"]
3895            .as_str()
3896            .expect("hash")
3897            .to_string();
3898        let size = vectors["attachment_hashes"][0]["size"]
3899            .as_u64()
3900            .expect("size");
3901
3902        let full = DataValue::Attachment {
3903            hash: hash.clone(),
3904            size,
3905            name: Some("plan.pdf".to_string()),
3906            mime: Some("application/pdf".to_string()),
3907        };
3908        assert_eq!(
3909            serde_json::to_string(&full).expect("encode"),
3910            vectors["attachment_references"][0]["json"]
3911                .as_str()
3912                .expect("json")
3913        );
3914
3915        // Absent, not null. A reader that has to tell one from the other has
3916        // been given two spellings of one value.
3917        let bare = DataValue::attachment(hash, size);
3918        assert_eq!(
3919            serde_json::to_string(&bare).expect("encode"),
3920            vectors["attachment_references"][1]["json"]
3921                .as_str()
3922                .expect("json")
3923        );
3924        assert!(!vectors["attachment_references"][1]["json"]
3925            .as_str()
3926            .expect("json")
3927            .contains("null"));
3928    }
3929
3930    #[test]
3931    fn data_purposes_encode_to_their_vectors() {
3932        let vectors = vectors();
3933        let hash = vectors["attachment_hashes"][0]["sha256"]
3934            .as_str()
3935            .expect("hash")
3936            .to_string();
3937        assert_eq!(
3938            serde_json::to_string(&DataPurpose::Attachment { hash }).expect("encode"),
3939            vectors["data_purposes"][0]["json"].as_str().expect("json")
3940        );
3941        assert_eq!(
3942            serde_json::to_string(&DataPurpose::Snapshot {
3943                doc: "notes".to_string(),
3944            })
3945            .expect("encode"),
3946            vectors["data_purposes"][1]["json"].as_str().expect("json")
3947        );
3948    }
3949
3950    #[test]
3951    fn blob_digests_match_their_vectors() {
3952        let vectors = vectors();
3953        for case in vectors["blob_digests"].as_array().expect("blob_digests") {
3954            let hex = case["input_hex"].as_str().expect("hex");
3955            let bytes: Vec<u8> = (0..hex.len())
3956                .step_by(2)
3957                .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
3958                .collect();
3959            assert_eq!(
3960                blob_digest(&bytes),
3961                case["digest"].as_str().expect("digest")
3962            );
3963        }
3964    }
3965}
3966
3967impl SyncBody {
3968    /// The custody class of this frame, or `None` for a kind custody never
3969    /// carries (`docs/spec/custody.md`, "The classes").
3970    ///
3971    /// Exhaustive on purpose: a new kind has to decide here whether a copy of
3972    /// it is idempotent in cost, rather than inheriting an answer.
3973    pub(crate) fn custody_class(&self) -> Option<&'static str> {
3974        match self {
3975            SyncBody::Versions { .. }
3976            | SyncBody::Delta { .. }
3977            | SyncBody::Snapshot { .. }
3978            | SyncBody::BlobGone { .. } => Some(crate::protocol::custody::CUSTODY_CLASS_DATA),
3979            // Not idempotent in cost: each acted-on copy of a request spends a
3980            // whole media transfer or a snapshot export, and a carried chunk
3981            // repeats an answer the requester already had.
3982            SyncBody::NeedSnapshot { .. } | SyncBody::NeedBlob { .. } | SyncBody::Chunk { .. } => {
3983                None
3984            }
3985        }
3986    }
3987}
3988
3989/// The custody class of a `__DATA_V1__` plaintext the depositor retains for
3990/// re-sealing, or `None` when it is not a sync frame this build reads, or is a
3991/// kind custody refuses by name.
3992///
3993/// Read the way an arriving frame is read: version before body, so a frame of
3994/// a future version is simply not deposited rather than misclassified.
3995pub(crate) fn custody_class_of_plaintext(plaintext: &str) -> Option<&'static str> {
3996    let body = plaintext.strip_prefix(internal_prefixes::DATA_V1)?;
3997    let value: serde_json::Value = serde_json::from_str(body).ok()?;
3998    if value.get("v").and_then(serde_json::Value::as_u64) != Some(u64::from(DATA_SYNC_V1)) {
3999        return None;
4000    }
4001    serde_json::from_value::<SyncBody>(value)
4002        .ok()?
4003        .custody_class()
4004}
4005
4006#[cfg(test)]
4007mod custody_class_tests {
4008    use super::*;
4009
4010    fn framed(body: &str) -> String {
4011        format!(
4012            "{}{{\"v\":{},{}",
4013            internal_prefixes::DATA_V1,
4014            DATA_SYNC_V1,
4015            &body[1..]
4016        )
4017    }
4018
4019    #[test]
4020    fn class_a_kinds_are_deposited_and_the_rest_are_not() {
4021        for (kind, expected) in [
4022            (
4023                "{\"k\":\"delta\",\"doc\":\"d\",\"blob\":\"AA==\"}",
4024                Some("data"),
4025            ),
4026            (
4027                "{\"k\":\"snap\",\"doc\":\"d\",\"blob\":\"AA==\"}",
4028                Some("data"),
4029            ),
4030            ("{\"k\":\"vv\",\"docs\":{}}", Some("data")),
4031            ("{\"k\":\"blob_gone\",\"hash\":\"h\"}", Some("data")),
4032            ("{\"k\":\"need_snap\",\"doc\":\"d\"}", None),
4033            ("{\"k\":\"need_blob\",\"hash\":\"h\"}", None),
4034            (
4035                "{\"k\":\"chunk\",\"hash\":\"h\",\"i\":0,\"n\":1,\"blob\":\"AA==\"}",
4036                None,
4037            ),
4038        ] {
4039            assert_eq!(
4040                custody_class_of_plaintext(&framed(kind)),
4041                expected,
4042                "{kind}"
4043            );
4044        }
4045    }
4046
4047    #[test]
4048    fn anything_but_a_readable_sync_frame_is_not_deposited() {
4049        assert_eq!(custody_class_of_plaintext("hello"), None);
4050        assert_eq!(
4051            custody_class_of_plaintext(&format!("{}not json", internal_prefixes::DATA_V1)),
4052            None
4053        );
4054        // A future version is not deposited rather than misclassified.
4055        let future = format!(
4056            "{}{{\"v\":{},\"k\":\"delta\",\"doc\":\"d\",\"blob\":\"AA==\"}}",
4057            internal_prefixes::DATA_V1,
4058            DATA_SYNC_V1 + 1
4059        );
4060        assert_eq!(custody_class_of_plaintext(&future), None);
4061    }
4062}