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        self.stage_outbox_reseal(
1129            &message.id,
1130            crate::protocol::types::OutboxReseal {
1131                content: plaintext,
1132                priority: message.priority,
1133                reply_to_msg: None,
1134                forwarded_from: None,
1135                content_type: message.content_type,
1136                media_metadata: None,
1137                rich: None,
1138            },
1139        );
1140
1141        let previous_transport = self.transport_manager.current_transport();
1142        match self.transport_manager.send(&message) {
1143            Ok(()) => {
1144                let current = self.transport_manager.current_transport();
1145                let _ = self.handle_send_success(&message, current);
1146            }
1147            Err(err) => {
1148                let current = self.transport_manager.current_transport();
1149                let _ = self.handle_send_failure(&message, current.or(previous_transport));
1150                debug!(peer = %peer, error = %err, "Sync frame deferred to the outbox");
1151            }
1152        }
1153    }
1154
1155    /// Push a freshly committed change to the peer this space replicates
1156    /// with.
1157    ///
1158    /// `origin` names the peer a change arrived from, when it did. Echoing a
1159    /// change back to whoever sent it is harmless — the merge absorbs it —
1160    /// but on a mesh it is a frame nobody needed, and a commit produced by
1161    /// applying a remote delta exports that delta again.
1162    pub(crate) fn push_data_delta(
1163        &mut self,
1164        space: &str,
1165        doc: &str,
1166        blob: &[u8],
1167        origin: Option<&str>,
1168    ) {
1169        let Some(channel) = self.space_channel(space, origin) else {
1170            return;
1171        };
1172        // Only a change made here: one applied from a peer is theirs to
1173        // settle, and settling it from here would be the echo `origin`
1174        // exists to stop. Armed before the size check so a change that went
1175        // out as a nudge is settled too; that nudge is one frame and can be
1176        // lost like any other.
1177        if origin.is_none() {
1178            self.arm_data_sync_settle(space, doc);
1179        }
1180        if blob.len() > MAX_SYNC_BLOB_BYTES {
1181            // Too big to inline, so the catch-up ladder has to fetch it: it
1182            // can answer with a compacted snapshot instead of raw history.
1183            // Offering now rather than waiting is what makes that happen. On
1184            // a link that never drops, "the peer's next version offer" is
1185            // not a time, and until it arrives both replicas believe they
1186            // agree.
1187            debug!(
1188                space,
1189                doc,
1190                bytes = blob.len(),
1191                "Change too large to push inline; offering versions instead"
1192            );
1193            self.nudge_data_sync(space, origin, "oversized_delta");
1194            return;
1195        }
1196        self.send_sync_frame(
1197            space,
1198            &channel,
1199            &SyncBody::Delta {
1200                doc: doc.to_string(),
1201                blob: BASE64.encode(blob),
1202            },
1203        );
1204    }
1205
1206    /// Start, or push out, the settle offer for `doc` in `space`.
1207    ///
1208    /// Keyed by space, not by document, so a burst across several documents
1209    /// is still one offer naming all of them. See [`DATA_SYNC_SETTLE_DELAY`].
1210    fn arm_data_sync_settle(&mut self, space: &str, doc: &str) {
1211        let due = Instant::now() + DATA_SYNC_SETTLE_DELAY;
1212        let (at, docs) = self
1213            .data_sync_settle
1214            .entry(space.to_string())
1215            .or_insert_with(|| (due, BTreeSet::new()));
1216        *at = due;
1217        docs.insert(doc.to_string());
1218    }
1219
1220    /// Send every settle offer whose space has been quiet for
1221    /// [`DATA_SYNC_SETTLE_DELAY`]. Driven by [`OfflineProtocol::process`],
1222    /// the binding's periodic tick, like every other timer in the core.
1223    ///
1224    /// The offer names only the documents committed since the last one and
1225    /// is marked partial, so the receiver draws no "never seen it" inference
1226    /// from the rest of the space. It is an ordinary offer and not a reply,
1227    /// because the side that is behind is the receiver, and only its
1228    /// counter-offer carries the version a catch-up is computed from. That
1229    /// counter-offer is a reply and provokes catch-up only, so the exchange
1230    /// is the same terminating 1.5 round trips every other offer starts, and
1231    /// nothing in it re-arms this: only a local commit does, and an import
1232    /// is never one.
1233    ///
1234    /// The offer is small; what it provokes is not. A receiver answers an
1235    /// offer that is not a reply with its complete version list for the
1236    /// space, not just the documents named, so one settle costs a whole-space
1237    /// counter-offer from the 1:1 peer, or from every member of a group, each
1238    /// an addressed frame spending a ratchet generation. That is why it is
1239    /// debounced per space rather than sent per commit. Scoping the
1240    /// counter-offer to a partial offer's names would cut it, and is a
1241    /// protocol change with its own termination argument.
1242    ///
1243    /// Neither consults nor stamps the 30-second offer window. Consulting it
1244    /// would swallow exactly the offers this exists for: the sweep that set
1245    /// the window most likely fired when the peer came into range, seconds
1246    /// before the burst. Stamping it would let an offer about a few documents
1247    /// suppress a sweep of the whole space. It needs no window of its own
1248    /// either: an entry is created only by a local commit and removed when
1249    /// it fires, so this sends at most one offer per space per quiet period,
1250    /// never more often than once per [`DATA_SYNC_SETTLE_DELAY`], and never
1251    /// without a commit behind it.
1252    ///
1253    /// In a group the offer goes to the whole roster rather than addressed to
1254    /// each member. The delta it follows was roster-wide, so any member may
1255    /// have missed it and there is no one member to ask; one roster-wide
1256    /// encryption costs the sender one ratchet generation every member
1257    /// observes, and resets the addressed-frame budget rather than spending
1258    /// it, where one addressed copy per member would spend a generation per
1259    /// member that the others never see. The channel is re-derived here, so
1260    /// a group whose replication gate has closed since the commit is sent
1261    /// nothing, exactly as the commit's own push would have been.
1262    pub(crate) fn send_due_data_settle_offers(&mut self, now: Instant) {
1263        let due: Vec<String> = self
1264            .data_sync_settle
1265            .iter()
1266            .filter(|(_, (at, _))| *at <= now)
1267            .map(|(space, _)| space.clone())
1268            .collect();
1269        for space in due {
1270            let Some((_, docs)) = self.data_sync_settle.remove(&space) else {
1271                continue;
1272            };
1273            let Some(channel) = self.space_channel(&space, None) else {
1274                continue;
1275            };
1276            let mut versions = BTreeMap::new();
1277            for doc in docs {
1278                // A document removed since its commit has a floor, which the
1279                // next sweep carries. Reading its version would be refused
1280                // (a removed name does not open), so skip it here rather
1281                // than log that refusal as a failure.
1282                if !self.data_holds_doc(&space, &doc) {
1283                    continue;
1284                }
1285                match self.data_doc_version(&space, &doc) {
1286                    Ok(version) => {
1287                        versions.insert(doc, version);
1288                    }
1289                    Err(err) => {
1290                        warn!(space, doc, error = %err, "Could not read a version to settle");
1291                    }
1292                }
1293            }
1294            if versions.is_empty() {
1295                continue;
1296            }
1297            debug!(
1298                space,
1299                cause = "settle",
1300                docs = versions.len(),
1301                "Local commits went quiet; offering their versions"
1302            );
1303            self.send_version_frames(
1304                &space,
1305                &channel,
1306                OfferContents {
1307                    docs: versions,
1308                    ..OfferContents::default()
1309                },
1310                false,
1311                true,
1312            );
1313        }
1314    }
1315
1316    // ---- inbound ----------------------------------------------------
1317
1318    /// Handle a `__DATA_V1__` frame that arrived sealed from `sender`.
1319    ///
1320    /// Every outcome is terminal. A frame is never deferred for a
1321    /// data-layer reason: deferral means "this same ciphertext will succeed
1322    /// once the session is ready" and nothing else, so using it for a
1323    /// corrupt blob or a disabled layer would spend the sender's whole retry
1324    /// budget on a frame that cannot ever be accepted.
1325    pub(crate) fn handle_data_sync_frame(&mut self, sender: &str, body: &str) {
1326        // The space is the sender. Never a field on the frame: a peer that
1327        // could name the space could write into a document it replicates
1328        // with somebody else.
1329        self.handle_data_sync_frame_over(sender, &SyncChannel::Peer, sender, body);
1330    }
1331
1332    /// A `__DATA_V1__` frame that arrived inside a *group* ciphertext.
1333    ///
1334    /// The space is the group, and the same structural argument holds as
1335    /// for 1:1: the group is not named by the frame, it is the group whose
1336    /// key opened the ciphertext. A member of one group cannot reach
1337    /// another group's documents without being a member of that group too,
1338    /// and the decrypt already proved membership and authenticated the
1339    /// sender.
1340    ///
1341    /// Answers go back to `sender` alone rather than to the roster. Every
1342    /// other member either has what was asked for or will ask for it
1343    /// themselves.
1344    #[cfg_attr(not(feature = "data"), allow(dead_code))]
1345    pub(crate) fn handle_group_data_sync_frame(
1346        &mut self,
1347        group_id: &str,
1348        sender: &str,
1349        body: &str,
1350    ) {
1351        self.handle_data_sync_frame_over(
1352            group_id,
1353            &SyncChannel::GroupDirected(sender.to_string()),
1354            sender,
1355            body,
1356        );
1357    }
1358
1359    fn handle_data_sync_frame_over(
1360        &mut self,
1361        space: &str,
1362        channel: &SyncChannel,
1363        sender: &str,
1364        body: &str,
1365    ) {
1366        if !self.config.data.enabled {
1367            // Parsing is never capability-gated, but applying is: the layer
1368            // is off, so the frame is dropped. Nothing is lost permanently —
1369            // whatever it carried is still on the sender, and the version
1370            // exchange after the layer comes back on asks for it again.
1371            debug!(peer = %sender, "Sync frame dropped: the data layer is off");
1372            return;
1373        }
1374
1375        // The version is read before the body so a future frame shape is
1376        // declined on its version rather than failing to parse. Reversing
1377        // these two would make every future format look like corruption.
1378        let value: serde_json::Value = match serde_json::from_str(body) {
1379            Ok(value) => value,
1380            Err(err) => {
1381                warn!(peer = %sender, error = %err, "Malformed sync frame");
1382                return;
1383            }
1384        };
1385        match value.get("v").and_then(serde_json::Value::as_u64) {
1386            Some(v) if v == DATA_SYNC_V1 as u64 => {}
1387            other => {
1388                debug!(peer = %sender, version = ?other, "Sync frame from an unsupported version");
1389                return;
1390            }
1391        }
1392        let frame: SyncBody = match serde_json::from_value(value) {
1393            Ok(frame) => frame,
1394            Err(err) => {
1395                warn!(peer = %sender, error = %err, "Unreadable sync frame body");
1396                return;
1397            }
1398        };
1399
1400        let space = space.to_string();
1401        let outcome = match frame {
1402            SyncBody::Versions {
1403                reply,
1404                partial,
1405                docs,
1406                gone,
1407                want,
1408            } => self.answer_version_offer(
1409                &space,
1410                channel,
1411                reply,
1412                partial,
1413                OfferContents { docs, gone, want },
1414            ),
1415            SyncBody::Delta { doc, blob } => {
1416                self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Delta)
1417            }
1418            SyncBody::Snapshot { doc, blob } => {
1419                self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Snapshot)
1420            }
1421            SyncBody::NeedSnapshot { doc } => self.answer_snapshot_request(&space, channel, &doc),
1422            SyncBody::NeedBlob { hash, to } => {
1423                self.answer_blob_request(&space, channel, &hash, to.as_deref())
1424            }
1425            SyncBody::BlobGone { hash } => self.report_blob_gone(&space, sender, &hash),
1426            // The group road only. A 1:1 fetch is answered over the media
1427            // path, which has its own reassembly, its own resource caps and
1428            // its own failure report; admitting a chunk here as well would
1429            // give one outcome two roads to arrive by, differing in every
1430            // bound they answer to and alike in nothing but the hash check
1431            // at the end.
1432            SyncBody::Chunk { .. } if matches!(channel, SyncChannel::Peer) => {
1433                warn!(
1434                    peer = %sender,
1435                    "A blob chunk arrived on a 1:1 session, where bytes ride the media path"
1436                );
1437                Ok(())
1438            }
1439            SyncBody::Chunk { hash, i, n, blob } => {
1440                self.accept_blob_chunk(&space, sender, &hash, i, n, &blob)
1441            }
1442        };
1443        if let Err(err) = outcome {
1444            warn!(peer = %sender, error = %err, "Sync frame could not be handled");
1445        }
1446    }
1447
1448    /// Answer a peer's version offer with what they are missing.
1449    fn answer_version_offer(
1450        &mut self,
1451        space: &str,
1452        channel: &SyncChannel,
1453        reply: bool,
1454        partial: bool,
1455        offer: OfferContents,
1456    ) -> Result<()> {
1457        let OfferContents {
1458            docs: theirs,
1459            gone,
1460            want,
1461        } = offer;
1462        // What the asker wants back. Over-long lists are truncated rather
1463        // than refused: the patterns are a request, and answering the first
1464        // few is closer to what was asked for than answering none. The bound
1465        // is what stops a peer making every document in the space cost a
1466        // walk of a list it chose the length of.
1467        let wanted = want.map(|mut patterns| {
1468            if patterns.len() > MAX_INTEREST_PATTERNS_PER_FRAME {
1469                warn!(
1470                    space,
1471                    patterns = patterns.len(),
1472                    "A version offer declares more interest patterns than are read"
1473                );
1474                patterns.truncate(MAX_INTEREST_PATTERNS_PER_FRAME);
1475            }
1476            patterns
1477        });
1478        let asked_for = |doc: &str| match &wanted {
1479            None => true,
1480            Some(patterns) => patterns
1481                .iter()
1482                .any(|pattern| super::data::interest_matches(pattern, doc)),
1483        };
1484        // Removals first, before anything is created or asked for. A name
1485        // the peer has removed must not be created from this same frame and
1486        // then deleted again, and a version below a floor must not be
1487        // answered with a question about content the floor already covers.
1488        for (doc, encoded) in &gone {
1489            if offline_protocol_data::validate_name(doc).is_err() {
1490                warn!(space, doc, "A removal names an invalid document");
1491                continue;
1492            }
1493            // Bound the decode by the floor budget, the way a blob is bound
1494            // by the frame budget: base64 grows by a third, so the encoded
1495            // length refuses an oversized floor before allocating for it.
1496            if encoded.len() > MAX_REMOVAL_FLOOR_BYTES * 4 / 3 + 4 {
1497                warn!(space, doc, "Oversized removal floor in a sync frame");
1498                continue;
1499            }
1500            // A removal costs a stored record for a name this device may
1501            // never have held, so it answers to the same ceiling every
1502            // other name a peer introduces does. `continue` rather than
1503            // `break`: a name already known here always admits, and a
1504            // removal for a held document sorted after a refused fresh name
1505            // is exactly the removal a full space still has to apply.
1506            if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1507                warn!(
1508                    space,
1509                    doc,
1510                    cap = MAX_DOCS_PER_SPACE,
1511                    "Space is at its document ceiling; ignoring a removal for a new name"
1512                );
1513                continue;
1514            }
1515            let floor = match BASE64.decode(encoded) {
1516                Ok(bytes) => VersionToken::from_bytes(bytes),
1517                Err(err) => {
1518                    warn!(space, doc, error = %err, "Undecodable removal floor in a sync frame");
1519                    continue;
1520                }
1521            };
1522            match self.data_apply_tombstone(space, doc, &floor) {
1523                Ok(outcome) => {
1524                    debug!(space, doc, ?outcome, "Applied a removal from a peer");
1525                }
1526                Err(err) => {
1527                    warn!(space, doc, error = %err, "Could not apply a removal from a peer");
1528                }
1529            }
1530        }
1531
1532        let mut ours = self.data_sync_versions(space).unwrap_or_default();
1533
1534        // Documents they have and we do not. A created document is empty,
1535        // and the only thing that ever fills it is this device asking for
1536        // its contents, so what is created here has to be asked about
1537        // before this frame is done with: the peer has already told us
1538        // everything it intends to and will not volunteer it again.
1539        let mut created = BTreeMap::new();
1540        for (doc, theirs_encoded) in &theirs {
1541            if ours.contains_key(doc) || offline_protocol_data::validate_name(doc).is_err() {
1542                continue;
1543            }
1544            // A version a removal already covers is not a document this
1545            // device has never seen: it is the one it removed. Creating it
1546            // would undo the removal, and asking for it would ask for
1547            // exactly the content the removal deleted.
1548            //
1549            // Only ever asked of a name this device does not hold, which is
1550            // what this loop is. Asked of one it does hold, the answer would
1551            // be wrong in the one case it matters: every version covers the
1552            // empty one, so a replica that re-created the name and has
1553            // nothing in it yet would read as holding removed content, and
1554            // the document it is waiting for would never be sent.
1555            if self.version_is_removed_content(space, doc, theirs_encoded) {
1556                debug!(space, doc, "Ignoring an offer of content a removal covers");
1557                continue;
1558            }
1559            // Our own interest, not theirs: what they hold is their
1560            // business, and what this device stores is ours. This is the
1561            // half that holds against a peer which never reads the field.
1562            if !self.data_wants(space, doc) {
1563                debug!(
1564                    space,
1565                    doc, "Not creating a document outside this space's interest"
1566                );
1567                continue;
1568            }
1569            // The same door a blob for an unknown document goes through,
1570            // and the same ceiling. Counting the versions read above
1571            // instead would undercount by every document whose version
1572            // could not be read, which is how a space gets past its cap.
1573            if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1574                warn!(
1575                    space,
1576                    cap = MAX_DOCS_PER_SPACE,
1577                    "Space is at its document ceiling; ignoring the rest of the offer"
1578                );
1579                break;
1580            }
1581            if self.data_create_doc(space, doc).is_err() {
1582                continue;
1583            }
1584            // Folded into `ours` rather than re-read from the whole space
1585            // below: one version read per frame instead of two, and the
1586            // answer then describes exactly the documents this leg judged.
1587            match self.data_doc_version(space, doc) {
1588                Ok(version) => {
1589                    ours.insert(doc.clone(), version.clone());
1590                    created.insert(doc.clone(), version);
1591                }
1592                Err(err) => {
1593                    warn!(space, doc, error = %err, "Created a document from an offer but cannot read its version");
1594                }
1595            }
1596        }
1597
1598        for (doc, ours_encoded) in &ours {
1599            // Their interest, not ours: this loop decides what to *send*.
1600            // Scoping by what they asked for is safe where scoping by the
1601            // names this frame happened to carry is not, because a document
1602            // they want and have never named is still in our list and still
1603            // matches, so the absence inference below still reaches it.
1604            if !asked_for(doc) {
1605                continue;
1606            }
1607            let Some(theirs_encoded) = theirs.get(doc) else {
1608                // They did not name this document. On a complete offer that
1609                // means they have never seen it and the whole document is
1610                // the answer. On a partial one it means nothing: the name
1611                // may be in a frame that has not arrived, or in no frame at
1612                // all because the offer was about a single document.
1613                if !partial {
1614                    self.offer_catch_up(space, channel, doc, None);
1615                }
1616                continue;
1617            };
1618            if theirs_encoded == ours_encoded {
1619                continue;
1620            }
1621            match BASE64.decode(theirs_encoded) {
1622                Ok(token) => {
1623                    self.offer_catch_up(space, channel, doc, Some(VersionToken::from_bytes(token)))
1624                }
1625                Err(err) => {
1626                    warn!(space, doc, error = %err, "Undecodable version token in a sync frame");
1627                }
1628            }
1629        }
1630
1631        // One answer, and the exchange is over. Answering an answer is how a
1632        // pair of replicas talk to each other forever.
1633        //
1634        // Either way the documents created above are asked about, which is
1635        // the invariant to keep: the counter-offer names them when there is
1636        // one, and when there is not they are asked for on their own. An
1637        // answering leg that skipped the question would leave a document
1638        // created from it empty for as long as the link stays up, because
1639        // the peer has said everything it had to say and nothing else here
1640        // ever asks.
1641        if !reply {
1642            let our_gone = self.tombstones_for(space, channel);
1643            let our_want = self.interest_for(space, channel);
1644            // The counter-offer carries the complete list, interest or no
1645            // interest. Scoping it would save a name per unwanted document
1646            // and buy a familiar hazard: a reply sweep narrowed on the
1647            // answering side is how a document the peer has never seen gets
1648            // dropped with no symptom, and the fact that this particular
1649            // narrowing is provably safe is not worth a rule the next reader
1650            // has to re-derive. What the interest saves is the *document*,
1651            // which the catch-up loop above already declined to send.
1652            //
1653            // Removals are not scoped either, for a reason that is not about
1654            // caution: a peer that narrowed its interest keeps what it
1655            // already held, so a removal for a document it no longer wants
1656            // is exactly the one it still needs.
1657            self.send_version_frames(
1658                space,
1659                channel,
1660                OfferContents {
1661                    docs: ours,
1662                    gone: our_gone,
1663                    want: our_want,
1664                },
1665                true,
1666                false,
1667            );
1668        } else {
1669            self.send_version_frames(
1670                space,
1671                channel,
1672                OfferContents {
1673                    docs: created,
1674                    ..OfferContents::default()
1675                },
1676                true,
1677                true,
1678            );
1679        }
1680        Ok(())
1681    }
1682
1683    /// Whether an offered version names content a removal here already
1684    /// covered.
1685    fn version_is_removed_content(&mut self, space: &str, doc: &str, encoded: &str) -> bool {
1686        match BASE64.decode(encoded) {
1687            Ok(bytes) => {
1688                self.data_version_below_floor(space, doc, &VersionToken::from_bytes(bytes))
1689            }
1690            // Undecodable is not "removed"; the arms below report it where
1691            // the decode is actually needed.
1692            Err(_) => false,
1693        }
1694    }
1695
1696    /// Send `doc` to the peer in whatever form can carry it.
1697    ///
1698    /// The ladder, cheapest first: the changes since their version, a whole
1699    /// document, and then nothing. "Nothing" is a real rung and it is
1700    /// reported rather than retried: a document too large for every inline
1701    /// form needs the media path, which the attachment stage brings, and a
1702    /// silent stall would be indistinguishable from a peer with no changes.
1703    fn offer_catch_up(
1704        &mut self,
1705        space: &str,
1706        channel: &SyncChannel,
1707        doc: &str,
1708        theirs: Option<VersionToken>,
1709    ) {
1710        if let Some(token) = theirs {
1711            match self.data_catch_up(space, doc, &token) {
1712                Ok(CatchUp::UpToDate) => return,
1713                Ok(CatchUp::Updates(bytes)) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
1714                    self.send_sync_frame(
1715                        space,
1716                        channel,
1717                        &SyncBody::Delta {
1718                            doc: doc.to_string(),
1719                            blob: BASE64.encode(bytes),
1720                        },
1721                    );
1722                    return;
1723                }
1724                // Either too large to inline or a gap no run of changes can
1725                // express. Both are answered by the snapshot below.
1726                Ok(_) => {}
1727                Err(err) => {
1728                    warn!(space, doc, error = %err, "Could not compute catch-up");
1729                    return;
1730                }
1731            }
1732        }
1733
1734        self.send_snapshot(space, channel, doc);
1735    }
1736
1737    /// Send a whole document, the top rung and the answer to every refusal
1738    /// below it.
1739    ///
1740    /// Terminal by construction: a snapshot provokes no answer of its own,
1741    /// which is what lets the refusals underneath it ask for one freely.
1742    fn send_snapshot(&mut self, space: &str, channel: &SyncChannel, doc: &str) {
1743        match self.data_export_snapshot(space, doc) {
1744            Ok(bytes) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
1745                self.send_sync_frame(
1746                    space,
1747                    channel,
1748                    &SyncBody::Snapshot {
1749                        doc: doc.to_string(),
1750                        blob: BASE64.encode(bytes),
1751                    },
1752                );
1753            }
1754            // Too large for any frame. The rung above every frame is the
1755            // media path, which is where a document this size goes.
1756            Ok(bytes) => self.carry_snapshot_over_media(space, channel, doc, bytes),
1757            Err(err) => {
1758                warn!(space, doc, error = %err, "Could not export document for catch-up");
1759            }
1760        }
1761    }
1762
1763    /// Send a document the media path carries, because no frame can.
1764    ///
1765    /// The top rung of the catch-up ladder and the last one. A document that
1766    /// cannot go this way either is reported rather than retried: nothing
1767    /// above this exists to try, and the two replicas stay apart until
1768    /// somebody makes the document smaller.
1769    ///
1770    /// Only ever 1:1, and it stayed that way when attachment bytes stopped
1771    /// being. The media path is a transfer to a confirmed session and two
1772    /// members of a group need not have one with each other, so a group
1773    /// space that reaches this rung reports instead. Carrying it as frames
1774    /// the way a blob now travels would need a rule this layer does not
1775    /// have: a blob is asked for, so a chunk of one is admitted against an
1776    /// outstanding request, while a snapshot is unsolicited by design and
1777    /// frames carrying one would be admitted on the sender's say-so alone.
1778    fn carry_snapshot_over_media(
1779        &mut self,
1780        space: &str,
1781        channel: &SyncChannel,
1782        doc: &str,
1783        bytes: Vec<u8>,
1784    ) {
1785        let size = bytes.len();
1786        let report = |protocol: &mut Self, reason: &str| {
1787            warn!(
1788                space,
1789                doc,
1790                bytes = size,
1791                reason,
1792                "Document cannot be replicated"
1793            );
1794            if let Ok(state) = crate::protocol::lock_shared_state(&protocol.shared_state) {
1795                state.emit_event(Event::DataDocUnsyncable {
1796                    space_id: space.to_string(),
1797                    doc_id: doc.to_string(),
1798                    bytes: size as u64,
1799                    reason: reason.to_string(),
1800                });
1801            }
1802        };
1803
1804        if !matches!(channel, SyncChannel::Peer) {
1805            report(self, "group_space_has_no_media_path");
1806            return;
1807        }
1808        if size > MAX_MEDIA_SNAPSHOT_BYTES {
1809            report(self, "over_record_ceiling");
1810            return;
1811        }
1812        if !self.data_media_active(space) {
1813            report(self, "peer_cannot_carry_snapshots");
1814            return;
1815        }
1816
1817        // Not a dead end and not reported as one: the document is already on
1818        // its way. This rung is re-entered on its own while a carriage runs,
1819        // because the peer stays stale for the whole of it and says so on
1820        // every exchange.
1821        let purpose = DataPurpose::Snapshot {
1822            doc: doc.to_string(),
1823        };
1824        if self.data_media_transfer_in_flight(space, &purpose) {
1825            debug!(
1826                space,
1827                doc, "This document is already crossing to the peer; not starting a second copy"
1828            );
1829            return;
1830        }
1831
1832        debug!(
1833            space,
1834            doc,
1835            bytes = size,
1836            "Carrying a snapshot over the media path"
1837        );
1838        if let Err(err) = self.send_media_inner(
1839            space.to_string(),
1840            bytes,
1841            doc.to_string(),
1842            ContentType::File,
1843            MediaSendOptions::default(),
1844            Some(purpose),
1845        ) {
1846            // Transient by nature: no confirmed session yet, the per-peer
1847            // transfer slots are full, or the protocol is not running.
1848            // Reported at debug and left for anti-entropy, which asks again
1849            // on the next exchange. Raising it as unsyncable would tell an
1850            // application a document is permanently stuck when it is merely
1851            // busy.
1852            debug!(space, doc, error = %err, "Snapshot carriage deferred");
1853        }
1854    }
1855
1856    /// Answer a peer that cannot apply what we sent it.
1857    ///
1858    /// Only a document already held is served. A request naming an unknown
1859    /// one would otherwise create it, which is a way to spend this device's
1860    /// storage that does not even require a blob.
1861    fn answer_snapshot_request(
1862        &mut self,
1863        space: &str,
1864        channel: &SyncChannel,
1865        doc: &str,
1866    ) -> Result<()> {
1867        if offline_protocol_data::validate_name(doc).is_err() {
1868            warn!(space, doc, "Snapshot request names an invalid document");
1869            return Ok(());
1870        }
1871        if !self.data_holds_doc(space, doc) {
1872            debug!(space, doc, "Snapshot request for a document we do not hold");
1873            return Ok(());
1874        }
1875        debug!(space, doc, "Serving a snapshot the peer asked for");
1876        self.send_snapshot(space, channel, doc);
1877        Ok(())
1878    }
1879
1880    /// Apply a blob from a peer, with the containment the engine needs.
1881    fn accept_remote_blob(
1882        &mut self,
1883        space: &str,
1884        channel: &SyncChannel,
1885        doc: &str,
1886        encoded: &str,
1887        kind: BlobKind,
1888    ) -> Result<()> {
1889        if offline_protocol_data::validate_name(doc).is_err() {
1890            warn!(space, doc, "Sync frame names an invalid document");
1891            return Ok(());
1892        }
1893        // A blob for a document this device does not hold creates one, so
1894        // this path is bounded by the same ceiling the offer loop is.
1895        if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1896            warn!(
1897                space,
1898                doc,
1899                cap = MAX_DOCS_PER_SPACE,
1900                "Space is at its document ceiling; refusing a new one"
1901            );
1902            return Ok(());
1903        }
1904        // Bound the decode by the frame budget: base64 grows by a third, so
1905        // checking the encoded length first refuses an oversized blob before
1906        // allocating for it.
1907        if encoded.len() > MAX_SYNC_BLOB_BYTES * 4 / 3 + 4 {
1908            warn!(space, doc, "Sync frame carries an oversized blob");
1909            return Ok(());
1910        }
1911        let blob = match BASE64.decode(encoded) {
1912            Ok(blob) => blob,
1913            Err(err) => {
1914                warn!(space, doc, error = %err, "Undecodable blob in a sync frame");
1915                return Ok(());
1916            }
1917        };
1918        if blob.len() > MAX_SYNC_BLOB_BYTES {
1919            warn!(
1920                space,
1921                doc,
1922                bytes = blob.len(),
1923                "Sync blob over the frame budget"
1924            );
1925            return Ok(());
1926        }
1927
1928        self.apply_contained_blob(space, channel, doc, blob, kind)
1929    }
1930
1931    /// Apply remote document bytes with the containment the engine needs.
1932    ///
1933    /// Split from [`Self::accept_remote_blob`] so bytes that arrived over
1934    /// the media path go through exactly this, rather than through a second
1935    /// copy of it. The road the bytes travelled says nothing about what they
1936    /// are: both roads carry an import that can still end the process, from
1937    /// a peer who is authenticated and may still be wrong.
1938    ///
1939    /// The frame budget is deliberately not checked here. It bounds what one
1940    /// frame may carry, which is a fact about frames; the media path has its
1941    /// own, larger bound because it has no such limit.
1942    fn apply_contained_blob(
1943        &mut self,
1944        space: &str,
1945        channel: &SyncChannel,
1946        doc: &str,
1947        blob: Vec<u8>,
1948        kind: BlobKind,
1949    ) -> Result<()> {
1950        if offline_protocol_data::validate_name(doc).is_err() {
1951            warn!(space, doc, "A remote blob names an invalid document");
1952            return Ok(());
1953        }
1954        if !self.data_wants(space, doc) {
1955            debug!(
1956                space,
1957                doc, "Refusing a blob for a document outside this space's interest"
1958            );
1959            return Ok(());
1960        }
1961        if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1962            warn!(
1963                space,
1964                doc,
1965                cap = MAX_DOCS_PER_SPACE,
1966                "Space is at its document ceiling; refusing a new one"
1967            );
1968            return Ok(());
1969        }
1970
1971        let digest = blob_digest(&blob);
1972        let mut record = self.load_sync_record(space);
1973        if record.quarantined.contains(&digest) {
1974            // This blob was in flight when a previous run died. It may be a
1975            // coincidence and it may be the reason; either way applying it
1976            // again to find out is a bad trade, because the cost of being
1977            // wrong is the process and the cost of being right is one
1978            // document change that the sender still holds.
1979            warn!(space, doc, "Refusing a quarantined blob");
1980            return Ok(());
1981        }
1982
1983        // A blob for a name with a removal floor is judged against the floor
1984        // before anything acts on it. A blob carrying nothing beyond the
1985        // floor is the content that removal deleted, arriving from a replica
1986        // that has not heard about it yet: importing it would recreate the
1987        // document. Terminal when it refuses, like every other refusal here:
1988        // the sender is told by the `gone` entry in our next offer, not by
1989        // an answer to this.
1990        //
1991        // The judgement is an engine decode, not a header read. The engine
1992        // decodes every change the blob carries into a scratch document to
1993        // find the version they end at, with the same decoder an import
1994        // runs, and ADR 0019 contains that decoder for a reason: a crafted
1995        // blob can end the process inside it, and under `panic = "abort"`
1996        // nothing catches that. So it runs inside its own in-flight marker,
1997        // exactly as the import below does. A blob that ends the process
1998        // here leaves its digest behind, and the sender's retry is refused
1999        // instead of ending the next process too. Outside the marker this
2000        // would be the one engine decode of a peer's bytes with no
2001        // quarantine, on a path a peer steers a blob onto by sending the
2002        // floor first.
2003        //
2004        // Paid only by a name that has a floor: two marker writes on a path
2005        // a space nobody has removed anything from never takes.
2006        if self.data_doc_has_floor(space, doc) {
2007            record.in_flight = Some(digest.clone());
2008            self.persist_sync_record(space, &record);
2009            let below_floor = self.data_blob_below_floor(space, doc, &blob);
2010            record.in_flight = None;
2011            self.persist_sync_record(space, &record);
2012            if below_floor {
2013                debug!(
2014                    space,
2015                    doc, "Refusing a blob that carries only content a removal covers"
2016                );
2017                return Ok(());
2018            }
2019        }
2020
2021        // Past every refusal, the blob carries something the removal never
2022        // saw, which is an edit made concurrently with it or after it. An
2023        // edit beats a removal, so the name comes back here, after the last
2024        // check that can refuse the blob and before the first step that
2025        // needs the name open. Brought back any earlier, a refused blob
2026        // would leave the name live with no document under it. The floor
2027        // stays, so the rest of the removed content is still refused from a
2028        // replica that has not heard about the removal.
2029        if let Err(err) = self.data_revive_removed_doc(space, doc) {
2030            warn!(space, doc, error = %err, "Could not bring back a removed document for a change that outlives its removal");
2031            return Ok(());
2032        }
2033
2034        // Local edits waiting in memory are committed and pushed *before*
2035        // the import, so they leave on their own delta.
2036        //
2037        // A commit exports everything since the last one, regardless of who
2038        // authored it. So a local edit still pending when a remote blob
2039        // applies is folded into the same delta as the imported change, and
2040        // that delta is then suppressed as an echo toward the one peer it
2041        // was owed to. The edit is durable and announced to nobody: the next
2042        // commit exports only what came after it, and on a link that never
2043        // drops there is no reconnect to run an exchange that would notice.
2044        // Draining first costs one extra delta record and keeps the echo
2045        // suppression judging a delta that really is nothing but the echo.
2046        //
2047        // Reported and stepped over rather than propagated. This is our own
2048        // housekeeping, and a document held over its cap fails here on every
2049        // flush while still needing to accept the remote deletions that
2050        // bring it back under: refusing the import for it would close the
2051        // only door out.
2052        //
2053        // Ahead of the marker below rather than inside its window, so the
2054        // window stays the length of one import. A crash during this flush
2055        // would otherwise quarantine a blob the engine never saw.
2056        //
2057        // The two errors say opposite things about what is still pending, so
2058        // they are told apart. `DocTooLarge` is raised after the delta record
2059        // was written, pushed and announced, and only the size verdict that
2060        // follows it failed: nothing is left in the pending set and there is
2061        // nothing to compensate for. Any other error may have left the commit
2062        // un-exported, because the delta-write failure rewinds it back into
2063        // the pending set. The import's own flush would then fold that edit
2064        // into the imported change and suppress the pair toward the one peer
2065        // it was owed to, which is exactly the loss this pre-flush exists to
2066        // prevent.
2067        let mut preflush_left_edits_pending = false;
2068        match self.data_flush(space, doc) {
2069            Ok(()) => {}
2070            Err(Error::DocTooLarge { .. }) => {
2071                debug!(
2072                    space,
2073                    doc,
2074                    "Local edits were flushed and pushed before applying a remote change; the \
2075                     document is over its cap and refuses further growth"
2076                );
2077            }
2078            Err(err) => {
2079                warn!(
2080                    space,
2081                    doc,
2082                    error = %err,
2083                    "Could not flush local edits before applying a remote change; they may \
2084                     still be pending"
2085                );
2086                preflush_left_edits_pending = true;
2087            }
2088        }
2089
2090        // Written *before* the engine is called. That ordering is the whole
2091        // mechanism: a blob that ends the process leaves its digest behind,
2092        // so the sender's next retry is refused instead of ending the next
2093        // process too.
2094        record.in_flight = Some(digest.clone());
2095        self.persist_sync_record(space, &record);
2096
2097        let outcome = self.data_apply_remote(space, doc, &blob);
2098
2099        record.in_flight = None;
2100        self.persist_sync_record(space, &record);
2101
2102        // Read before the match below consumes the outcome.
2103        let applied = matches!(outcome, Ok(RemoteImport::Applied));
2104
2105        match outcome {
2106            Ok(RemoteImport::Applied) => {
2107                debug!(space, doc, "Applied a remote change");
2108            }
2109            Ok(RemoteImport::AlreadyHave) => {}
2110            Ok(RemoteImport::Parked) => match kind {
2111                // The change is held behind a predecessor that has not
2112                // arrived. Asking for the gap now is what makes the parked
2113                // change durable: it lives only in the engine's memory, so a
2114                // restart before the predecessor lands would lose it, and
2115                // the version exchange is what brings both back. Scoped to
2116                // this document, because this document is what is missing
2117                // something.
2118                BlobKind::Delta => {
2119                    debug!(space, doc, "Remote change parked; asking for the gap");
2120                    self.request_doc_catch_up(space, channel, doc);
2121                }
2122                // A snapshot is the top of the ladder. Asking again returns
2123                // the same bytes, so this is reported and left, rather than
2124                // retried until one side gives up or the battery does.
2125                BlobKind::Snapshot => {
2126                    warn!(
2127                        space,
2128                        doc,
2129                        "A snapshot from the peer did not apply; these replicas cannot be \
2130                         reconciled inline"
2131                    );
2132                }
2133            },
2134            Ok(RemoteImport::RefusedTrimmedHistory) => match kind {
2135                // Nothing the peer can compute from our version closes this
2136                // gap: they would recompute the same refused delta, and
2137                // answering with a version offer is how that becomes an
2138                // exchange neither side can end. Name what would work.
2139                BlobKind::Delta => {
2140                    debug!(space, doc, "Remote change needs a snapshot; asking for one");
2141                    self.send_sync_frame(
2142                        space,
2143                        channel,
2144                        &SyncBody::NeedSnapshot {
2145                            doc: doc.to_string(),
2146                        },
2147                    );
2148                }
2149                // The end of the line, and the one outcome this layer
2150                // cannot recover from. The peer forked below a point this
2151                // replica compacted away, so the ancestors their changes
2152                // need were deleted here rather than left out of the
2153                // message: no frame carries them back, including the whole
2154                // document, which is what just arrived. Reported plainly
2155                // rather than retried, because a retry returns these same
2156                // bytes and refusing them is the only thing keeping the
2157                // process alive.
2158                BlobKind::Snapshot => {
2159                    warn!(
2160                        space,
2161                        doc,
2162                        "A snapshot from the peer cannot merge into this replica; the two \
2163                         have diverged past what replication can reconcile"
2164                    );
2165                }
2166            },
2167            Err(err) => match kind {
2168                BlobKind::Delta => {
2169                    warn!(space, doc, error = %err, "Remote change refused");
2170                }
2171                // The top of the ladder, so there is nothing above it to
2172                // ask for. Unreconcilable divergence is refused before it
2173                // gets here rather than failing here, which leaves this for
2174                // bytes that are simply bad.
2175                BlobKind::Snapshot => {
2176                    warn!(space, doc, error = %err, "A snapshot from the peer was unreadable");
2177                }
2178            },
2179        }
2180
2181        // A pre-flush that failed followed by an import that applied is the
2182        // one combination where a local edit can have been folded into the
2183        // imported change and then suppressed toward the peer it was owed
2184        // to. Nothing on the wire carries it and no trigger is left to
2185        // notice, so the gap is announced and the peer asks for what it is
2186        // missing. The alternative, sending the fold on with the suppression
2187        // lifted, would hand the peer its own change back.
2188        //
2189        // Gated on `Applied` for the reason that also makes it terminate.
2190        // The fold needs the import's own flush to have succeeded, and a
2191        // storage failure that is still failing fails that one too, which
2192        // makes the import answer `Err`. So every nudge costs a fresh
2193        // transient failure that recovered inside one frame, and the offers
2194        // cannot sustain each other. `Applied` and not "anything but `Err`"
2195        // for the same reason: the outcomes that did not apply left the edit
2196        // pending, where the next pre-flush still owes it to the peer.
2197        //
2198        // This is why a document over its cap must answer `Applied` rather
2199        // than `DocTooLarge`, and does: its flush wrote the fold and then
2200        // refused further growth, so the edit is stranded exactly as here,
2201        // on the documents whose only pending edit is the deletion that
2202        // brings them back under.
2203        //
2204        // One residual is left uncompensated on purpose. A `persist_space`
2205        // failure after a flush that succeeded also strands a fold, and is
2206        // indistinguishable here from a flush that failed. Offering on every
2207        // `Err` would cover it and break the argument above: a store that
2208        // keeps failing would then emit an offer per frame, and those do
2209        // sustain each other. The narrower gap is the better trade.
2210        //
2211        // `origin` is `None`, not the space: what may be stranded here is
2212        // this device's own edit, so naming the space would suppress the
2213        // announcement toward the only peer that could act on it.
2214        if preflush_left_edits_pending && applied {
2215            self.nudge_data_sync(space, None, "preflush_failure");
2216        }
2217
2218        Ok(())
2219    }
2220
2221    // ---- attachments -------------------------------------------------
2222
2223    /// Key of a pending fetch: the space asked in, then the blob wanted.
2224    ///
2225    /// Composed rather than keyed by hash alone because the same bytes may
2226    /// be referenced from two spaces, and an answer from one peer must not
2227    /// close a question put to another.
2228    fn attachment_fetch_key(space: &str, hash: &str) -> String {
2229        format!("{space}{GROUP_OFFER_KEY_SEP}{hash}")
2230    }
2231
2232    /// The SHA-256 of `bytes`, in the spelling an attachment reference uses.
2233    ///
2234    /// Exposed because an application writing a reference needs the address
2235    /// of its own bytes, and computing it anywhere else risks a second
2236    /// spelling of the same hash.
2237    pub fn data_attachment_hash(bytes: &[u8]) -> String {
2238        hex_encode(&Sha256::digest(bytes))
2239    }
2240
2241    /// Ask the peer a 1:1 space is named after for the bytes behind a
2242    /// reference.
2243    ///
2244    /// Pull rather than push, and the reason is bandwidth somebody else
2245    /// pays for: a space may hold references to more bytes than a phone
2246    /// wants over Bluetooth, so the decision to spend that belongs to the
2247    /// application, taken per blob, at the moment somebody opens one.
2248    ///
2249    /// The answer arrives later as [`Event::DataAttachmentReceived`] or
2250    /// [`Event::DataAttachmentUnavailable`], never inline.
2251    ///
2252    /// Group spaces are refused in this version. A blob travels the media
2253    /// path, which is a 1:1 transfer to a confirmed session, and two members
2254    /// of a group need not have one with each other.
2255    pub fn data_fetch_attachment(&mut self, space: &str, hash: &str) -> Result<()> {
2256        if !self.config.data.enabled {
2257            return Err(Error::DataDisabled);
2258        }
2259        Self::validate_attachment_hash(hash)?;
2260        if self.group_space_roster(space).is_some() {
2261            return Err(Error::InvalidArgument(format!(
2262                "space {space} is a group: a reference names bytes and not who holds them, so \
2263                 ask a member with data_fetch_attachment_from"
2264            )));
2265        }
2266        if !self.data_media_active(space) {
2267            return Err(Error::InvalidArgument(format!(
2268                "peer {space} did not advertise attachment carriage"
2269            )));
2270        }
2271
2272        let key = Self::attachment_fetch_key(space, hash);
2273        self.expire_attachment_fetches();
2274        // The requester's own bound, which the spec makes a MUST and which
2275        // the holder's window cannot substitute for: an application retrying
2276        // a spinner on a timer would otherwise seal and send one frame per
2277        // retry, and the holder answering none of them is the case that
2278        // provokes the retries.
2279        if let Some(pending) = self.pending_attachment_fetches.get(&key) {
2280            if Instant::now().duration_since(pending.last_seen) < DATA_SYNC_OFFER_INTERVAL {
2281                debug!(
2282                    space,
2283                    "Blob fetch repeated inside its window; already asked"
2284                );
2285                return Ok(());
2286            }
2287        }
2288        if self.pending_attachment_fetches.len() >= MAX_PENDING_ATTACHMENT_FETCHES
2289            && !self.pending_attachment_fetches.contains_key(&key)
2290        {
2291            // Oldest by last sign of life rather than by age, which is what
2292            // keeps a fetch whose bytes are actively arriving out of this:
2293            // every chunk of an answer refreshes its entry, so a live
2294            // carriage sits among the newest and is never the one displaced.
2295            if let Some(oldest) = self
2296                .pending_attachment_fetches
2297                .iter()
2298                .min_by_key(|(_, pending)| pending.last_seen)
2299                .map(|(key, _)| key.clone())
2300            {
2301                let evicted = self.pending_attachment_fetches.remove(&oldest);
2302                // Reported, exactly as an expiry is. Eviction reaches the
2303                // same end state by a different road: the fetch is over and
2304                // no bytes will ever be admitted for it, so an application
2305                // told nothing is left showing the spinner that the whole
2306                // refusal mechanism exists to end.
2307                Self::report_fetch_ended(
2308                    &self.shared_state,
2309                    &oldest,
2310                    evicted
2311                        .as_ref()
2312                        .and_then(|pending| pending.holder.as_deref()),
2313                    "evicted",
2314                );
2315            }
2316        }
2317        self.pending_attachment_fetches
2318            .insert(key, PendingFetch::to_peer());
2319
2320        self.send_sync_frame(
2321            space,
2322            &SyncChannel::Peer,
2323            &SyncBody::NeedBlob {
2324                hash: hash.to_string(),
2325                to: None,
2326            },
2327        );
2328        Ok(())
2329    }
2330
2331    /// Send one chunk frame as a holder would, without the hash check
2332    /// `data_provide_attachment` makes on the way out.
2333    ///
2334    /// Test-only, and it exists because the receive-side hash check has no
2335    /// other way to be reached: the send surface refuses to carry bytes that
2336    /// do not hash to the address they are sent under, so a lying holder
2337    /// cannot be staged through it.
2338    #[cfg(test)]
2339    pub(crate) fn send_chunk_for_test(
2340        &mut self,
2341        space: &str,
2342        channel: &SyncChannel,
2343        hash: &str,
2344        index: u32,
2345        total: u32,
2346        blob: &str,
2347    ) {
2348        self.send_sync_frame(
2349            space,
2350            channel,
2351            &SyncBody::Chunk {
2352                hash: hash.to_string(),
2353                i: index,
2354                n: total,
2355                blob: blob.to_string(),
2356            },
2357        );
2358    }
2359
2360    /// Take one piece of a blob a member is sending.
2361    ///
2362    /// Admitted only against a fetch this device started, and only from the
2363    /// member it was put to. Both halves matter: the first is what stops a
2364    /// member spending this device's memory on bytes nobody asked for, and
2365    /// the second is what stops a different member answering a question that
2366    /// was not put to them. Neither is a substitute for the hash check at
2367    /// the end, which is the only thing that says what the bytes *are*.
2368    ///
2369    /// Provokes no answer in any outcome, which is what keeps `need_blob` a
2370    /// single-hop question.
2371    fn accept_blob_chunk(
2372        &mut self,
2373        space: &str,
2374        sender: &str,
2375        hash: &str,
2376        index: u32,
2377        total: u32,
2378        encoded: &str,
2379    ) -> Result<()> {
2380        if Self::validate_attachment_hash(hash).is_err() {
2381            warn!(space, "A blob chunk names something that is not a hash");
2382            return Ok(());
2383        }
2384        // The receiver's own bound, not a restatement of the sender's. A
2385        // holder that ignores the cap would otherwise spend this device's
2386        // memory up to whatever it felt like declaring.
2387        if total == 0 || total > MAX_GROUP_BLOB_CHUNKS || index >= total {
2388            warn!(
2389                space,
2390                index, total, "A blob chunk declares a shape this layer does not carry"
2391            );
2392            return Ok(());
2393        }
2394        let key = Self::attachment_fetch_key(space, hash);
2395        let Some(pending) = self.pending_attachment_fetches.get_mut(&key) else {
2396            debug!(space, "Discarding a blob chunk nothing asked for");
2397            return Ok(());
2398        };
2399        if !pending.is_answered_by(space, sender) {
2400            warn!(
2401                space,
2402                sender, "Discarding a blob chunk from a member this fetch was not put to"
2403            );
2404            return Ok(());
2405        }
2406        let bytes = match BASE64.decode(encoded) {
2407            Ok(bytes) => bytes,
2408            Err(err) => {
2409                warn!(space, error = %err, "Undecodable blob chunk");
2410                return Ok(());
2411            }
2412        };
2413        if bytes.len() > MAX_SYNC_BLOB_BYTES {
2414            warn!(
2415                space,
2416                bytes = bytes.len(),
2417                "A blob chunk is over the frame budget"
2418            );
2419            return Ok(());
2420        }
2421
2422        // Arriving bytes are the sign of life the timeout measures from, and
2423        // the refresh happens before anything below can refuse this chunk:
2424        // a carriage in progress must not expire because one piece of it was
2425        // malformed.
2426        pending.last_seen = Instant::now();
2427        let assembly = pending.assembly.get_or_insert_with(BlobAssembly::default);
2428        if assembly.total == 0 {
2429            assembly.total = total;
2430        } else if assembly.total != total {
2431            // One of the two is lying about the shape. The chunk is refused
2432            // rather than the fetch, because the hash at the end is what
2433            // decides the question either way, and ending the fetch here
2434            // would let one bad frame cancel a carriage that is otherwise
2435            // arriving.
2436            warn!(
2437                space,
2438                declared = assembly.total,
2439                arriving = total,
2440                "A blob chunk contradicts the count its siblings declared"
2441            );
2442            return Ok(());
2443        }
2444        assembly.chunks.insert(index, bytes);
2445        if (assembly.chunks.len() as u32) < assembly.total {
2446            return Ok(());
2447        }
2448
2449        // Complete. Whether it is the *right* blob is a separate question,
2450        // and the only one authentication cannot answer.
2451        let assembled: Vec<u8> = assembly
2452            .chunks
2453            .values()
2454            .flat_map(|chunk| chunk.iter().copied())
2455            .collect();
2456        let asked = self
2457            .pending_attachment_fetches
2458            .remove(&key)
2459            .and_then(|pending| pending.holder);
2460
2461        let actual = Self::data_attachment_hash(&assembled);
2462        if actual != hash {
2463            warn!(
2464                space,
2465                sender, "Assembled blob does not hash to what was asked for"
2466            );
2467            Self::report_fetch_ended(&self.shared_state, &key, asked.as_deref(), "hash_mismatch");
2468            return Ok(());
2469        }
2470        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
2471            state.emit_event(Event::DataAttachmentReceived {
2472                space_id: space.to_string(),
2473                peer_id: sender.to_string(),
2474                hash: hash.to_string(),
2475                data: BASE64.encode(&assembled),
2476            });
2477        }
2478        Ok(())
2479    }
2480
2481    /// Ask one member of a group for the bytes behind a reference.
2482    ///
2483    /// A group needs the member named, where a 1:1 space does not: the
2484    /// reference replicates to everybody and says nothing about who holds
2485    /// the bytes. The SDK does not walk the roster on the application's
2486    /// behalf, because each miss costs the whole silence timeout and a
2487    /// reference nobody holds would spend one per member. The application
2488    /// knows who wrote the reference; that is the member to ask.
2489    ///
2490    /// The answer arrives as frames under the group key rather than as a
2491    /// transfer, so it needs no pairwise session with that member, and it is
2492    /// bounded at 32 pieces of 32 KiB: 1 MiB in all. Anything larger travels
2493    /// over a 1:1 session, and the holder is told so at the call.
2494    pub fn data_fetch_attachment_from(
2495        &mut self,
2496        space: &str,
2497        peer: &str,
2498        hash: &str,
2499    ) -> Result<()> {
2500        if !self.config.data.enabled {
2501            return Err(Error::DataDisabled);
2502        }
2503        Self::validate_attachment_hash(hash)?;
2504        let Some(members) = self.group_space_roster(space) else {
2505            return Err(Error::InvalidArgument(format!(
2506                "space {space} is not a group; use data_fetch_attachment"
2507            )));
2508        };
2509        if !members.iter().any(|member| member == peer) {
2510            return Err(Error::InvalidArgument(format!(
2511                "{peer} is not a member of {space}"
2512            )));
2513        }
2514        // This device is in the roster it just read, so naming itself is a
2515        // question it would put to nobody and then wait out the silence
2516        // timeout for. An application walking a roster to choose a member
2517        // reaches this, and the honest answer is that it has the bytes or
2518        // it does not.
2519        if peer == self.local_id {
2520            return Err(Error::InvalidArgument(
2521                "a group fetch names another member to ask, and a device is not a peer of itself"
2522                    .to_string(),
2523            ));
2524        }
2525        // A question, and the only directed frame this layer puts to a
2526        // member who asked for nothing. Every other one answers something
2527        // that member sent, which is itself proof they intercept these
2528        // frames; this one carries no such proof, so it needs the positive
2529        // knowledge the roster-wide gate requires of every member before a
2530        // frame is broadcast to them. Without it a member on a build that
2531        // does not intercept `__DATA_V1__` is handed the question as chat
2532        // text, which is the single failure that gate exists to refuse.
2533        //
2534        // An inviter attests this for members a device has never dealt with
2535        // directly, which is what keeps the ordinary case working: a group
2536        // is not built out of existing 1:1 contacts.
2537        if !self.data_group_frames_known_received(peer) {
2538            return Err(Error::InvalidArgument(format!(
2539                "nothing is known about whether member {peer} receives replication \
2540                 frames inside a group, and a question they cannot intercept would \
2541                 be shown to them as text"
2542            )));
2543        }
2544        // Refused only on knowledge, never on its absence. Members do not
2545        // exchange key packages with each other, so "we have not heard" is
2546        // the ordinary case and must not read as "they cannot": the
2547        // inviter's attestation fills it in where there is one, and where
2548        // there is not, asking and hearing nothing is the honest outcome
2549        // and the silence timeout reports it.
2550        if self.data_group_blob_known_absent(peer) {
2551            return Err(Error::InvalidArgument(format!(
2552                "member {peer} does not carry attachment bytes inside a group"
2553            )));
2554        }
2555
2556        let key = Self::attachment_fetch_key(space, hash);
2557        self.expire_attachment_fetches();
2558        match self.pending_attachment_fetches.get(&key) {
2559            // The same question asked twice, which is what the window is
2560            // for: a spinner on a timer would otherwise seal and send one
2561            // frame per retry.
2562            Some(pending)
2563                if pending.holder.as_deref() == Some(peer)
2564                    && Instant::now().duration_since(pending.last_seen)
2565                        < DATA_SYNC_OFFER_INTERVAL =>
2566            {
2567                debug!(
2568                    space,
2569                    peer, "Blob fetch repeated inside its window; already asked"
2570                );
2571                return Ok(());
2572            }
2573            // A different member is a different question, and the one an
2574            // application falls back to when the first goes quiet. The
2575            // record is keyed by the space and the blob, neither of which
2576            // changes, so without this a fallback inside the window
2577            // returned `Ok` and asked nobody, and after it silently threw
2578            // away whatever the first member had already sent.
2579            //
2580            // Displacing the first is reported like any other eviction,
2581            // naming the member it was put to, so an application tracking
2582            // a question per member is not left holding one that never
2583            // ends.
2584            Some(pending) if pending.holder.as_deref() != Some(peer) => {
2585                let displaced = self.pending_attachment_fetches.remove(&key);
2586                Self::report_fetch_ended(
2587                    &self.shared_state,
2588                    &key,
2589                    displaced
2590                        .as_ref()
2591                        .and_then(|pending| pending.holder.as_deref()),
2592                    "evicted",
2593                );
2594            }
2595            _ => {}
2596        }
2597        if self.pending_attachment_fetches.len() >= MAX_PENDING_ATTACHMENT_FETCHES
2598            && !self.pending_attachment_fetches.contains_key(&key)
2599        {
2600            if let Some(oldest) = self
2601                .pending_attachment_fetches
2602                .iter()
2603                .min_by_key(|(_, pending)| pending.last_seen)
2604                .map(|(key, _)| key.clone())
2605            {
2606                let evicted = self.pending_attachment_fetches.remove(&oldest);
2607                Self::report_fetch_ended(
2608                    &self.shared_state,
2609                    &oldest,
2610                    evicted
2611                        .as_ref()
2612                        .and_then(|pending| pending.holder.as_deref()),
2613                    "evicted",
2614                );
2615            }
2616        }
2617        self.pending_attachment_fetches
2618            .insert(key, PendingFetch::to_member(peer));
2619
2620        self.send_sync_frame(
2621            space,
2622            &SyncChannel::GroupDirected(peer.to_string()),
2623            &SyncBody::NeedBlob {
2624                hash: hash.to_string(),
2625                to: Some(peer.to_string()),
2626            },
2627        );
2628        Ok(())
2629    }
2630
2631    /// Answer a peer's request with the bytes.
2632    ///
2633    /// The bytes come from the application because this SDK never held them:
2634    /// a blob does not enter a document and does not enter protocol state,
2635    /// so the only copy is wherever the app put it.
2636    ///
2637    /// Refuses bytes that do not hash to `hash`. That check is here rather
2638    /// than only on the receiving side so a mistake is reported to the app
2639    /// that made it, while it still has the file in hand, instead of
2640    /// travelling the whole media path to be refused by somebody else.
2641    pub fn data_provide_attachment(
2642        &mut self,
2643        space: &str,
2644        peer: &str,
2645        hash: &str,
2646        bytes: Vec<u8>,
2647    ) -> Result<()> {
2648        if !self.config.data.enabled {
2649            return Err(Error::DataDisabled);
2650        }
2651        Self::validate_attachment_hash(hash)?;
2652        if bytes.is_empty() {
2653            return Err(Error::InvalidArgument(
2654                "an attachment must have bytes".to_string(),
2655            ));
2656        }
2657        let group = self.group_space_roster(space);
2658        if group.is_none() && space != peer {
2659            return Err(Error::InvalidArgument(format!(
2660                "space {space} is not the 1:1 space with {peer}"
2661            )));
2662        }
2663        if let Some(members) = &group {
2664            if !members.iter().any(|member| member == peer) {
2665                return Err(Error::InvalidArgument(format!(
2666                    "{peer} is not a member of {space}"
2667                )));
2668            }
2669        }
2670        let actual = Self::data_attachment_hash(&bytes);
2671        if actual != hash {
2672            return Err(Error::InvalidArgument(format!(
2673                "these bytes hash to {actual}, not to {hash}"
2674            )));
2675        }
2676
2677        // A group answer leaves as frames rather than as a transfer, and
2678        // leaves all at once: that is what keeps the request a single-hop
2679        // question, and it is why the size is bounded here rather than by
2680        // the transfer layer's own limit.
2681        if group.is_some() {
2682            let chunks = bytes.len().div_ceil(MAX_SYNC_BLOB_BYTES);
2683            if chunks > MAX_GROUP_BLOB_CHUNKS as usize {
2684                return Err(Error::InvalidArgument(format!(
2685                    "an attachment carried inside a group is at most {} bytes, and these are {}: \
2686                     send it over a 1:1 session instead",
2687                    MAX_GROUP_BLOB_CHUNKS as usize * MAX_SYNC_BLOB_BYTES,
2688                    bytes.len()
2689                )));
2690            }
2691            let channel = SyncChannel::GroupDirected(peer.to_string());
2692            for (index, chunk) in bytes.chunks(MAX_SYNC_BLOB_BYTES).enumerate() {
2693                self.send_sync_frame(
2694                    space,
2695                    &channel,
2696                    &SyncBody::Chunk {
2697                        hash: hash.to_string(),
2698                        i: index as u32,
2699                        n: chunks as u32,
2700                        blob: BASE64.encode(chunk),
2701                    },
2702                );
2703            }
2704            return Ok(());
2705        }
2706
2707        // Answering twice is not an error to report back: the app did as it
2708        // was asked, and the bytes are on their way. Idempotent rather than
2709        // refused, because a request repeated after its window reopens is
2710        // ordinary and an app has no way to see that its first answer is
2711        // still in flight.
2712        let purpose = DataPurpose::Attachment {
2713            hash: hash.to_string(),
2714        };
2715        if self.data_media_transfer_in_flight(peer, &purpose) {
2716            debug!(
2717                peer,
2718                "These bytes are already crossing to this peer; not starting a second copy"
2719            );
2720            return Ok(());
2721        }
2722
2723        self.send_media_inner(
2724            peer.to_string(),
2725            bytes,
2726            // The media layer wants a file name and an attachment has none
2727            // that is load-bearing: the display name lives on the reference
2728            // in the document, where every replica can already read it.
2729            hash.to_string(),
2730            ContentType::File,
2731            MediaSendOptions::default(),
2732            Some(purpose),
2733        )?;
2734        Ok(())
2735    }
2736
2737    /// Tell a peer their fetch will not be answered.
2738    ///
2739    /// A first-class answer rather than silence. The asking side cannot
2740    /// otherwise tell a peer that no longer holds the bytes from one that is
2741    /// merely slow, and shows a person a spinner that never resolves.
2742    pub fn data_decline_attachment(&mut self, space: &str, peer: &str, hash: &str) -> Result<()> {
2743        if !self.config.data.enabled {
2744            return Err(Error::DataDisabled);
2745        }
2746        Self::validate_attachment_hash(hash)?;
2747        let group = self.group_space_roster(space);
2748        if group.is_none() && space != peer {
2749            return Err(Error::InvalidArgument(format!(
2750                "space {space} is not the 1:1 space with {peer}"
2751            )));
2752        }
2753        let channel = match &group {
2754            Some(members) => {
2755                if !members.iter().any(|member| member == peer) {
2756                    return Err(Error::InvalidArgument(format!(
2757                        "{peer} is not a member of {space}"
2758                    )));
2759                }
2760                SyncChannel::GroupDirected(peer.to_string())
2761            }
2762            None => {
2763                // Gated like every other frame in this file, and it is the
2764                // one that was not. A refusal toward a peer that never
2765                // advertised the capability is unreadable to them, so the
2766                // caller believes they answered and the asking side keeps
2767                // waiting; toward a blocked peer it is a liveness signal
2768                // blocking exists to withhold.
2769                //
2770                // A group needs no equivalent: the refusal rides the group
2771                // key, which every member can already open.
2772                if !self.data_media_active(peer) {
2773                    return Err(Error::InvalidArgument(format!(
2774                        "peer {peer} did not advertise attachment carriage"
2775                    )));
2776                }
2777                SyncChannel::Peer
2778            }
2779        };
2780        self.send_sync_frame(
2781            space,
2782            &channel,
2783            &SyncBody::BlobGone {
2784                hash: hash.to_string(),
2785            },
2786        );
2787        Ok(())
2788    }
2789
2790    /// Whether a peer's request for one blob should reach the application.
2791    ///
2792    /// Two bounds, because a peer controls both how often it asks and what it
2793    /// asks for. The per-hash window stops a repeat; the per-peer budget
2794    /// stops a flood of distinct hashes, which the window alone cannot see as
2795    /// a flood at all because every hash in it is new.
2796    ///
2797    /// Kept out of the version-offer window map deliberately. Every other key
2798    /// in that map is a peer or a peer-and-group, both bounded by a roster.
2799    /// This one would be bounded by nothing, and that map has no cap and no
2800    /// sweep.
2801    fn blob_request_due(&mut self, peer: &str, hash: &str) -> bool {
2802        let now = Instant::now();
2803        self.blob_request_windows
2804            .retain(|_, seen| now.duration_since(*seen) < DATA_SYNC_OFFER_INTERVAL);
2805
2806        let key = format!("{peer}{GROUP_OFFER_KEY_SEP}{hash}");
2807        if self.blob_request_windows.contains_key(&key) {
2808            debug!(peer, "Blob request repeated inside its window; ignoring");
2809            return false;
2810        }
2811
2812        let live_for_peer = self
2813            .blob_request_windows
2814            .keys()
2815            .filter(|key| {
2816                key.split_once(GROUP_OFFER_KEY_SEP)
2817                    .is_some_and(|(asker, _)| asker == peer)
2818            })
2819            .count();
2820        if live_for_peer >= MAX_BLOB_REQUESTS_PER_WINDOW {
2821            warn!(
2822                peer,
2823                budget = MAX_BLOB_REQUESTS_PER_WINDOW,
2824                "Peer is over its blob-request budget; ignoring"
2825            );
2826            return false;
2827        }
2828
2829        // The global bound, which holds when a peer's budget resets and when
2830        // many peers each stay under theirs.
2831        if self.blob_request_windows.len() >= MAX_BLOB_REQUEST_WINDOWS {
2832            if let Some(oldest) = self
2833                .blob_request_windows
2834                .iter()
2835                .min_by_key(|(_, seen)| **seen)
2836                .map(|(key, _)| key.clone())
2837            {
2838                self.blob_request_windows.remove(&oldest);
2839            }
2840        }
2841        self.blob_request_windows.insert(key, now);
2842        true
2843    }
2844
2845    fn validate_attachment_hash(hash: &str) -> Result<()> {
2846        offline_protocol_data::validate_attachment_hash(hash)
2847            .map_err(|err| Error::InvalidArgument(err.to_string()))
2848    }
2849
2850    /// Whether this device is waiting for the blob behind `hash` from `peer`.
2851    ///
2852    /// Read before a transfer is admitted rather than after it completes.
2853    /// Refusing at the end still refuses, but only once the bytes have been
2854    /// buffered, reassembled and checksummed, which is the storage and the
2855    /// battery the check exists to protect.
2856    pub(crate) fn awaiting_attachment(&self, peer: &str, hash: &str) -> bool {
2857        self.pending_attachment_fetches
2858            .contains_key(&Self::attachment_fetch_key(peer, hash))
2859    }
2860
2861    /// Forget everything remembered about attachments with one peer.
2862    ///
2863    /// Composed through [`Self::attachment_fetch_key`] rather than by
2864    /// re-spelling the separator, which is what the separator constant exists
2865    /// for: a key built one way and matched another is a leak that no test
2866    /// notices until the two spellings diverge.
2867    pub(crate) fn forget_attachment_state(&mut self, peer: &str) {
2868        let prefix = Self::attachment_fetch_key(peer, "");
2869        // Reported, not merely dropped. This is a road that ends a fetch
2870        // without bytes like any other, and it is reached while an
2871        // application is waiting: a peer re-keying without the capability,
2872        // an unblock, a peer forgotten to keep a set bounded. Removing the
2873        // record silently also forecloses the expiry that would have
2874        // reported it later, so the spinner this event exists to end never
2875        // ends at all.
2876        let ended: Vec<(String, Option<String>)> = self
2877            .pending_attachment_fetches
2878            .iter()
2879            // Two roads reach the same peer. On a 1:1 space the peer *is*
2880            // the space, so the key carries it; in a group the space is the
2881            // group and the member asked is inside the record. A question
2882            // put to a peer this device has stopped replicating with cannot
2883            // be answered by either road, so both go, and a group fetch
2884            // left behind would hold its slot until it timed out.
2885            .filter(|(key, pending)| {
2886                key.starts_with(&prefix) || pending.holder.as_deref() == Some(peer)
2887            })
2888            .map(|(key, pending)| (key.clone(), pending.holder.clone()))
2889            .collect();
2890        for (key, holder) in ended {
2891            self.pending_attachment_fetches.remove(&key);
2892            Self::report_fetch_ended(&self.shared_state, &key, holder.as_deref(), "peer_gone");
2893        }
2894        self.blob_request_windows
2895            .retain(|key, _| !key.starts_with(&prefix));
2896    }
2897
2898    /// [`Self::forget_attachment_state`] for every peer at once, for the
2899    /// road that forgets every peer at once.
2900    ///
2901    /// Not a loop over the roster, because the caller has already emptied
2902    /// it: the capability sets are cleared wholesale when the bound on
2903    /// remembered peers is reached, so by then there is no peer left to name
2904    /// and a fetch keyed by one would be swept by nothing.
2905    pub(crate) fn end_every_pending_fetch(&mut self) {
2906        for (key, pending) in std::mem::take(&mut self.pending_attachment_fetches) {
2907            Self::report_fetch_ended(
2908                &self.shared_state,
2909                &key,
2910                pending.holder.as_deref(),
2911                "peer_gone",
2912            );
2913        }
2914    }
2915
2916    /// Report a fetch this device has given up on, keyed the way
2917    /// [`Self::attachment_fetch_key`] composes them.
2918    ///
2919    /// Shared by every road that ends a fetch without bytes, because they all
2920    /// owe the application the same thing and a second spelling of this event
2921    /// is a road that quietly owes it nothing. `reason` is a fixed token this
2922    /// crate chooses, never a peer's words: see the producer rule in
2923    /// `telemetry::scrub_event`.
2924    ///
2925    /// `holder` is the member the question was put to, which every caller
2926    /// reads off the record it is ending. It is carried rather than derived
2927    /// because it cannot be: the key composes the *space*, and in a group
2928    /// the space is not the peer.
2929    fn report_fetch_ended(
2930        shared_state: &std::sync::Arc<std::sync::Mutex<crate::protocol::SharedState>>,
2931        key: &str,
2932        holder: Option<&str>,
2933        reason: &str,
2934    ) {
2935        let Some((space, hash)) = key.split_once(GROUP_OFFER_KEY_SEP) else {
2936            return;
2937        };
2938        debug!(space, reason, "A blob fetch ended with no bytes");
2939        if let Ok(state) = crate::protocol::lock_shared_state(shared_state) {
2940            state.emit_event(Event::DataAttachmentUnavailable {
2941                space_id: space.to_string(),
2942                // The member the question was put to. A 1:1 space IS the
2943                // peer, which is why the key alone composes both there; a
2944                // group space is one scope with many members, so the record
2945                // carries the one asked and this reads it. Falling back to
2946                // the space in a group would hand an application a group id
2947                // where this event's own documentation promises the peer
2948                // that was asked, and every road but the refusal would
2949                // report it.
2950                peer_id: holder.unwrap_or(space).to_string(),
2951                hash: hash.to_string(),
2952                reason: reason.to_string(),
2953            });
2954        }
2955    }
2956
2957    /// Note that the answer to a fetch is on its way.
2958    ///
2959    /// A transfer that is visibly progressing is the answer this device is
2960    /// waiting for, so the question must not expire while it arrives. Without
2961    /// this the record times out mid-carriage and
2962    /// [`Self::accept_data_media_payload`] then drops a blob that fully
2963    /// arrived as one nobody asked for, which no retry can fix: the retry
2964    /// carries the same bytes over the same radio and expires at the same
2965    /// point.
2966    ///
2967    /// Only an attachment has a record to refresh. A snapshot is unsolicited
2968    /// by design and keeps none.
2969    pub(crate) fn refresh_attachment_fetch(&mut self, peer: &str, purpose: &DataPurpose) {
2970        let DataPurpose::Attachment { hash } = purpose else {
2971            return;
2972        };
2973        let key = Self::attachment_fetch_key(peer, hash);
2974        if let Some(seen_at) = self.pending_attachment_fetches.get_mut(&key) {
2975            seen_at.last_seen = Instant::now();
2976        }
2977    }
2978
2979    /// Whether a transfer for exactly this purpose is already on its way to
2980    /// this peer.
2981    ///
2982    /// Both roads onto the media path are re-entered by things that recur on
2983    /// their own: a peer stays stale for the whole multi-minute carriage of a
2984    /// document, so every anti-entropy exchange in that window reaches the
2985    /// top rung again, and an application answering a repeated request has no
2986    /// way to know its first answer is still in flight.
2987    ///
2988    /// Without this check those repeats become second copies of one transfer.
2989    /// A peer may hold only [`MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER`] at
2990    /// once, so the duplicates halve the throughput of the transfer that
2991    /// matters and then fill the slots, and the next thing to fail is the
2992    /// application's own media send, with a limit error whose cause it cannot
2993    /// see: the transfers holding those slots are invisible to it by design.
2994    ///
2995    /// [`MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER`]: crate::constants::MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER
2996    fn data_media_transfer_in_flight(&self, recipient: &str, purpose: &DataPurpose) -> bool {
2997        self.outbound_media_transfers.values().any(|transfer| {
2998            transfer.recipient == recipient && transfer.data_purpose.as_ref() == Some(purpose)
2999        })
3000    }
3001
3002    /// Give up on fetches nobody answered, and say so.
3003    ///
3004    /// The reporting half is the point. A holder is only asked to answer
3005    /// `need_blob`, never required to, so a fetch can go unanswered forever
3006    /// with nothing wrong anywhere. Expiring the entry silently would leave
3007    /// the asking application exactly where the whole refusal mechanism
3008    /// exists to stop it being: showing somebody a spinner that never
3009    /// resolves.
3010    pub(crate) fn expire_attachment_fetches(&mut self) {
3011        let now = Instant::now();
3012        let expired: Vec<(String, Option<String>)> = self
3013            .pending_attachment_fetches
3014            .iter()
3015            .filter(|(_, pending)| {
3016                now.duration_since(pending.last_seen) >= ATTACHMENT_FETCH_TIMEOUT
3017            })
3018            .map(|(key, pending)| (key.clone(), pending.holder.clone()))
3019            .collect();
3020        for (key, holder) in expired {
3021            self.pending_attachment_fetches.remove(&key);
3022            Self::report_fetch_ended(&self.shared_state, &key, holder.as_deref(), "timeout");
3023        }
3024    }
3025
3026    /// A peer wants the bytes behind a reference.
3027    ///
3028    /// Handed straight to the application, because the application is the
3029    /// only party that has them. Rate limited per blob on the window that
3030    /// suppresses version offers: a peer retrying a fetch must not turn into
3031    /// a stream of events, and the answer to the second ask within the
3032    /// window is the same as the answer to the first.
3033    fn answer_blob_request(
3034        &mut self,
3035        space: &str,
3036        channel: &SyncChannel,
3037        hash: &str,
3038        to: Option<&str>,
3039    ) -> Result<()> {
3040        if Self::validate_attachment_hash(hash).is_err() {
3041            warn!(space, "Blob request names something that is not a hash");
3042            return Ok(());
3043        }
3044        // The member that asked, which in a group is not the space. A
3045        // roster-wide request is refused rather than answered: there is
3046        // nobody to send the bytes to, and answering it would mean choosing
3047        // a member on the asker's behalf.
3048        let asker = match channel {
3049            SyncChannel::Peer => space.to_string(),
3050            SyncChannel::GroupDirected(member) => member.clone(),
3051            SyncChannel::GroupBroadcast => {
3052                warn!(
3053                    space,
3054                    "A blob request arrived addressed to the whole roster; ignoring"
3055                );
3056                return Ok(());
3057            }
3058        };
3059        // Inside a group the question names the member it is for, and one
3060        // that names somebody else is not ours to answer. The sender's
3061        // directed frame is promoted to the whole roster when its ratchet
3062        // budget runs out, so it arrives at every member either way; without
3063        // this check every member's application would be asked for bytes
3064        // that only one of them can have accepted.
3065        if !matches!(channel, SyncChannel::Peer) {
3066            match to {
3067                Some(member) if member == self.local_id => {}
3068                Some(_) => {
3069                    debug!(
3070                        space,
3071                        "A blob request names another member; not ours to answer"
3072                    );
3073                    return Ok(());
3074                }
3075                None => {
3076                    warn!(
3077                        space,
3078                        "A blob request inside a group names no member; ignoring"
3079                    );
3080                    return Ok(());
3081                }
3082            }
3083        }
3084        // A request this device could not answer is not put to the
3085        // application. Both answers to `DataAttachmentRequested` refuse
3086        // toward a peer that never advertised carriage: `provide` cannot
3087        // reach the media path and `decline` cannot reach the wire. Emitting
3088        // it anyway hands an app a question with no legal reply, which reads
3089        // to whoever handles it as an SDK that rejects its own events.
3090        //
3091        // Ahead of the rate limiter rather than behind it, so a peer that
3092        // cannot be answered spends no window slot: the budget exists to
3093        // bound work this device would otherwise do, and there is none here.
3094        let answerable = match channel {
3095            SyncChannel::Peer => self.data_media_active(space),
3096            // In a group the question is whether *this* device can answer,
3097            // and it can: the bytes leave as frames under the group key,
3098            // which every member can already open. What the asker speaks is
3099            // their problem, and they only get an answer because they asked.
3100            _ => self.config.data.enabled,
3101        };
3102        if !answerable {
3103            warn!(
3104                space,
3105                "A blob request came from a peer that never advertised carriage; ignoring"
3106            );
3107            return Ok(());
3108        }
3109        // Budgeted against the member that asked rather than the space, or
3110        // one member could spend the whole group's window.
3111        if !self.blob_request_due(&asker, hash) {
3112            return Ok(());
3113        }
3114        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3115            state.emit_event(Event::DataAttachmentRequested {
3116                space_id: space.to_string(),
3117                peer_id: asker,
3118                hash: hash.to_string(),
3119            });
3120        }
3121        Ok(())
3122    }
3123
3124    /// A peer says the bytes are gone. End the fetch.
3125    ///
3126    /// Checked the same way [`Self::answer_blob_request`] is, and for the
3127    /// same reason rather than a hypothetical one: a fetch is 1:1 state, and
3128    /// a frame arriving over a group would compose a key from a group id.
3129    /// Unreachable today because a group id cannot collide with an address,
3130    /// which is exactly the kind of accident a later refactor removes.
3131    fn report_blob_gone(&mut self, space: &str, sender: &str, hash: &str) -> Result<()> {
3132        if Self::validate_attachment_hash(hash).is_err() {
3133            warn!(space, "Blob refusal names something that is not a hash");
3134            return Ok(());
3135        }
3136        let key = Self::attachment_fetch_key(space, hash);
3137        // Only a fetch this device actually made is reported, and only by
3138        // the peer it was put to. Otherwise a peer could emit refusals for
3139        // blobs nobody asked about, and in a group any member could end a
3140        // question that was put to somebody else.
3141        let answers_us = self
3142            .pending_attachment_fetches
3143            .get(&key)
3144            .is_some_and(|pending| pending.is_answered_by(space, sender));
3145        if !answers_us {
3146            debug!(
3147                space,
3148                sender, "A blob refusal answers no question we put to this peer"
3149            );
3150            return Ok(());
3151        }
3152        self.pending_attachment_fetches.remove(&key);
3153        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3154            state.emit_event(Event::DataAttachmentUnavailable {
3155                space_id: space.to_string(),
3156                peer_id: sender.to_string(),
3157                hash: hash.to_string(),
3158                reason: "declined".to_string(),
3159            });
3160        }
3161        Ok(())
3162    }
3163
3164    // ---- what arrives over the media path ------------------------------
3165
3166    /// A data-purposed media transfer completed. Route it by its purpose.
3167    ///
3168    /// The space is the authenticated wire sender, never anything the
3169    /// transfer said about itself, which is the same rule every sync frame
3170    /// follows and for the same reason: a space a peer can name is a space a
3171    /// peer can reach.
3172    ///
3173    /// Gated on the kill switch, which is belt to the storage seam's braces
3174    /// rather than the thing that stops an import: `require_data_storage`
3175    /// already fails closed with `DataDisabled`, so a device with the layer
3176    /// off writes nothing regardless. This makes the refusal local and free,
3177    /// and keeps the rule uniform across both roads into this layer. The
3178    /// admission check in `receive` is the one that earns its keep, because
3179    /// it is the one that refuses before a whole blob is buffered.
3180    pub(crate) fn accept_data_media_payload(
3181        &mut self,
3182        sender: &str,
3183        purpose: &DataPurpose,
3184        bytes: Vec<u8>,
3185    ) {
3186        if !self.config.data.enabled {
3187            debug!(
3188                peer = %sender,
3189                "Document-layer transfer dropped: the data layer is off"
3190            );
3191            return;
3192        }
3193        match purpose {
3194            DataPurpose::Attachment { hash } => {
3195                let key = Self::attachment_fetch_key(sender, hash);
3196                if self.pending_attachment_fetches.remove(&key).is_none() {
3197                    warn!(
3198                        peer = %sender,
3199                        "Attachment bytes arrived for a fetch this device never made; dropping"
3200                    );
3201                    return;
3202                }
3203                // The address is checked against the bytes, not against what
3204                // the sender says about them. This is what makes fetching
3205                // from an authenticated peer safe without trusting that
3206                // peer: the worst a wrong answer achieves is no answer.
3207                let actual = Self::data_attachment_hash(&bytes);
3208                if &actual != hash {
3209                    warn!(
3210                        peer = %sender,
3211                        "Attachment bytes do not hash to what was asked for; dropping"
3212                    );
3213                    if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3214                        state.emit_event(Event::DataAttachmentUnavailable {
3215                            space_id: sender.to_string(),
3216                            peer_id: sender.to_string(),
3217                            hash: hash.clone(),
3218                            reason: "hash_mismatch".to_string(),
3219                        });
3220                    }
3221                    return;
3222                }
3223                if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3224                    state.emit_event(Event::DataAttachmentReceived {
3225                        space_id: sender.to_string(),
3226                        peer_id: sender.to_string(),
3227                        hash: hash.clone(),
3228                        data: BASE64.encode(&bytes),
3229                    });
3230                }
3231            }
3232            DataPurpose::Snapshot { doc } => {
3233                if bytes.len() > MAX_MEDIA_SNAPSHOT_BYTES {
3234                    warn!(
3235                        peer = %sender,
3236                        bytes = bytes.len(),
3237                        "A carried snapshot is larger than a record can hold; refusing"
3238                    );
3239                    return;
3240                }
3241                // Straight into the containment every remote blob goes
3242                // through. Arriving by a longer road changes nothing about
3243                // what these bytes are: an import that can still end the
3244                // process, from a peer who is exactly who they say they are.
3245                let doc = doc.clone();
3246                if let Err(err) = self.apply_contained_blob(
3247                    sender,
3248                    &SyncChannel::Peer,
3249                    &doc,
3250                    bytes,
3251                    BlobKind::Snapshot,
3252                ) {
3253                    warn!(peer = %sender, doc, error = %err, "A carried snapshot was refused");
3254                }
3255            }
3256        }
3257    }
3258
3259    /// A data-purposed media transfer failed. Say so where it can be acted on.
3260    pub(crate) fn report_data_media_failure(
3261        &mut self,
3262        sender: &str,
3263        purpose: &DataPurpose,
3264        reason: &str,
3265    ) {
3266        match purpose {
3267            DataPurpose::Attachment { hash } => {
3268                let key = Self::attachment_fetch_key(sender, hash);
3269                // Only a fetch still outstanding is reported, the same rule
3270                // the completion and refusal roads already follow. A fetch
3271                // that ended while its transfer was still moving (declined
3272                // mid-carriage, evicted, expired) has had its one answer,
3273                // and a second one for the same hash reads to an
3274                // application as a second failure of a thing it is no
3275                // longer waiting for.
3276                if self.pending_attachment_fetches.remove(&key).is_none() {
3277                    debug!(
3278                        peer = %sender,
3279                        reason,
3280                        "A transfer failed for a fetch that has already ended"
3281                    );
3282                    return;
3283                }
3284                if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
3285                    state.emit_event(Event::DataAttachmentUnavailable {
3286                        space_id: sender.to_string(),
3287                        peer_id: sender.to_string(),
3288                        hash: hash.clone(),
3289                        reason: reason.to_string(),
3290                    });
3291                }
3292            }
3293            // Nothing to report to an application: a snapshot that did not
3294            // arrive is a gap anti-entropy will find again, and the next
3295            // exchange asks for it afresh.
3296            DataPurpose::Snapshot { doc } => {
3297                debug!(peer = %sender, doc, reason, "A carried snapshot did not arrive");
3298            }
3299        }
3300    }
3301
3302    // ---- the crash record -------------------------------------------
3303
3304    /// Read a space's replication bookkeeping, promoting anything left in
3305    /// flight by a previous run into the quarantine.
3306    fn load_sync_record(&mut self, space: &str) -> SyncRecord {
3307        let Some(storage) = self.data_storage_for_sync() else {
3308            return SyncRecord::default();
3309        };
3310        let mut record =
3311            match self.read_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space) {
3312                Ok(Some(bytes)) => serde_json::from_slice::<SyncRecord>(&bytes).unwrap_or_default(),
3313                _ => SyncRecord::default(),
3314            };
3315        if let Some(digest) = record.in_flight.take() {
3316            warn!(
3317                space,
3318                "A previous run did not survive applying a remote change; quarantining it"
3319            );
3320            if !record.quarantined.contains(&digest) {
3321                record.quarantined.push(digest);
3322                while record.quarantined.len() > MAX_QUARANTINED_BLOBS {
3323                    record.quarantined.remove(0);
3324                }
3325            }
3326            self.persist_sync_record(space, &record);
3327        }
3328        record
3329    }
3330
3331    fn persist_sync_record(&mut self, space: &str, record: &SyncRecord) {
3332        let Some(storage) = self.data_storage_for_sync() else {
3333            return;
3334        };
3335        let bytes = match serde_json::to_vec(record) {
3336            Ok(bytes) => bytes,
3337            Err(err) => {
3338                warn!(space, error = %err, "Could not encode replication bookkeeping");
3339                return;
3340            }
3341        };
3342        if let Err(err) =
3343            self.write_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space, &bytes)
3344        {
3345            // Not fatal, and deliberately not a refusal to apply: failing to
3346            // write the marker costs the crash protection for this one blob,
3347            // while refusing would stop replication on a storage hiccup.
3348            warn!(space, error = %err, "Could not persist replication bookkeeping");
3349        }
3350    }
3351}
3352
3353#[cfg(test)]
3354mod tests {
3355    use super::*;
3356
3357    #[test]
3358    fn a_name_in_both_maps_costs_two_entries_of_the_frame_budget() {
3359        let entry = |n: usize, version: bool, floor: bool| -> OfferEntry {
3360            (
3361                format!("doc{n}"),
3362                version.then(|| "AQID".to_string()),
3363                floor.then(|| "BAUG".to_string()),
3364            )
3365        };
3366
3367        let full: Vec<OfferEntry> = (0..MAX_DOCS_PER_VERSION_FRAME)
3368            .map(|n| entry(n, true, false))
3369            .collect();
3370        assert_eq!(offer_batches(&full).len(), 1);
3371
3372        let one_over: Vec<OfferEntry> = (0..=MAX_DOCS_PER_VERSION_FRAME)
3373            .map(|n| entry(n, true, false))
3374            .collect();
3375        assert_eq!(offer_batches(&one_over).len(), 2);
3376
3377        // Every name came back after a removal, so each costs the frame two
3378        // versions: half the budget in names is the whole budget in bytes.
3379        let dual: Vec<OfferEntry> = (0..=MAX_DOCS_PER_VERSION_FRAME / 2)
3380            .map(|n| entry(n, true, true))
3381            .collect();
3382        let batches = offer_batches(&dual);
3383        assert_eq!(
3384            batches.len(),
3385            2,
3386            "a frame of names in both maps was built to twice the byte budget"
3387        );
3388        assert_eq!(
3389            batches.iter().map(|batch| batch.len()).sum::<usize>(),
3390            dual.len(),
3391            "an entry was dropped or duplicated across the split"
3392        );
3393
3394        assert_eq!(
3395            offer_batches(&[]).len(),
3396            1,
3397            "an empty offer is still one frame: it is what tells a peer to send everything"
3398        );
3399    }
3400
3401    #[test]
3402    fn a_frame_carries_its_version_where_a_future_build_can_find_it() {
3403        // The version has to be readable off a frame whose body this build
3404        // cannot parse, or every future format looks like corruption to
3405        // every older install.
3406        let body = SyncBody::Delta {
3407            doc: "notes".to_string(),
3408            blob: "AAAA".to_string(),
3409        };
3410        let json = serde_json::to_string(&body).unwrap();
3411        let framed = format!("{{\"v\":{},{}", DATA_SYNC_V1, &json[1..]);
3412
3413        let value: serde_json::Value = serde_json::from_str(&framed).unwrap();
3414        assert_eq!(value.get("v").and_then(|v| v.as_u64()), Some(1));
3415        assert_eq!(value.get("k").and_then(|v| v.as_str()), Some("delta"));
3416
3417        let round_trip: SyncBody = serde_json::from_value(value).unwrap();
3418        assert!(matches!(round_trip, SyncBody::Delta { doc, .. } if doc == "notes"));
3419    }
3420
3421    #[test]
3422    fn a_version_frame_round_trips() {
3423        let body = SyncBody::Versions {
3424            reply: true,
3425            partial: true,
3426            docs: BTreeMap::from([("notes".to_string(), "dgA=".to_string())]),
3427            gone: BTreeMap::from([("old".to_string(), "ZwA=".to_string())]),
3428            want: Some(vec!["notes".to_string(), "draft*".to_string()]),
3429        };
3430        let json = serde_json::to_string(&body).unwrap();
3431        let parsed: SyncBody = serde_json::from_str(&json).unwrap();
3432        match parsed {
3433            SyncBody::Versions {
3434                reply,
3435                partial,
3436                docs,
3437                gone,
3438                want,
3439            } => {
3440                assert!(reply);
3441                assert!(partial);
3442                assert_eq!(docs.get("notes").map(String::as_str), Some("dgA="));
3443                assert_eq!(gone.get("old").map(String::as_str), Some("ZwA="));
3444                assert_eq!(
3445                    want.as_deref(),
3446                    Some(["notes".to_string(), "draft*".to_string()].as_slice())
3447                );
3448            }
3449            other => panic!("expected a version frame, got {other:?}"),
3450        }
3451    }
3452
3453    #[test]
3454    fn an_offer_with_no_removals_carries_no_removal_field() {
3455        // Byte-identical to what a build that predates removals sends, which
3456        // is what keeps every frozen vector above valid and every space
3457        // nobody has removed anything from as cheap as it was.
3458        let json = serde_json::to_string(&SyncBody::Versions {
3459            reply: false,
3460            partial: false,
3461            docs: BTreeMap::new(),
3462            gone: BTreeMap::new(),
3463            want: None,
3464        })
3465        .unwrap();
3466        assert_eq!(
3467            json,
3468            r#"{"k":"vv","reply":false,"partial":false,"docs":{}}"#
3469        );
3470    }
3471
3472    #[test]
3473    fn an_offer_that_says_nothing_about_completeness_is_read_as_complete() {
3474        // The two flags default to the shape an ordinary whole-space offer
3475        // has, so a frame that omits them behaves like one. Getting this
3476        // backwards would silence the inference that carries a document a
3477        // peer has never seen.
3478        let parsed: SyncBody = serde_json::from_str(r#"{"k":"vv","docs":{}}"#).unwrap();
3479        match parsed {
3480            SyncBody::Versions {
3481                reply,
3482                partial,
3483                docs,
3484                gone,
3485                want,
3486            } => {
3487                assert!(!reply);
3488                assert!(!partial);
3489                assert!(docs.is_empty());
3490                assert!(
3491                    want.is_none(),
3492                    "an offer from a build that predates interest read as asking for a subset"
3493                );
3494                assert!(
3495                    gone.is_empty(),
3496                    "an offer from a build that predates removals read as removing something"
3497                );
3498            }
3499            other => panic!("expected a version frame, got {other:?}"),
3500        }
3501    }
3502
3503    #[test]
3504    fn a_snapshot_request_round_trips_and_carries_no_bytes() {
3505        // It names a document and nothing else: the request exists because
3506        // the asker cannot express its own gap, so anything it added would
3507        // be the thing that was already refused.
3508        let body = SyncBody::NeedSnapshot {
3509            doc: "notes".to_string(),
3510        };
3511        let json = serde_json::to_string(&body).unwrap();
3512        assert_eq!(json, r#"{"k":"need_snap","doc":"notes"}"#);
3513
3514        let parsed: SyncBody = serde_json::from_str(&json).unwrap();
3515        assert!(matches!(parsed, SyncBody::NeedSnapshot { doc } if doc == "notes"));
3516    }
3517
3518    #[test]
3519    fn digests_separate_blobs_and_repeat_for_the_same_one() {
3520        assert_eq!(blob_digest(b"one"), blob_digest(b"one"));
3521        assert_ne!(blob_digest(b"one"), blob_digest(b"two"));
3522        assert_eq!(blob_digest(b"one").len(), 32);
3523    }
3524}
3525
3526/// The frozen wire vectors for `__DATA_V1__`.
3527///
3528/// The conformance surface a second implementation is written against, and
3529/// the reason this layer is a protocol rather than an SDK feature. The file
3530/// was computed independently of the code it pins, so a mismatch is evidence
3531/// about the code rather than a disagreement between two copies of the same
3532/// mistake.
3533///
3534/// When one of these fails the wire format has changed. Bump the frame
3535/// version and negotiate a new `data_versions` entry. Do NOT edit the
3536/// expected values to make the test pass: every shipped install still speaks
3537/// the old ones, and a silent change here is a silent break there.
3538#[cfg(test)]
3539mod golden_vectors {
3540    use super::*;
3541
3542    const VECTORS: &str = include_str!("../../tests/data/data-sync-v1.vectors.json");
3543
3544    fn vectors() -> serde_json::Value {
3545        serde_json::from_str(VECTORS).expect("the vector file is JSON")
3546    }
3547
3548    /// Frame the way [`OfflineProtocol::send_sync_frame`] does, which is the
3549    /// only place the version is spliced in. Reproduced rather than called
3550    /// because calling it needs a whole protocol, and what is pinned here is
3551    /// the encoding, not the send path.
3552    fn framed(body: &SyncBody) -> String {
3553        let json = serde_json::to_string(body).expect("encode");
3554        format!(
3555            "{}{{\"v\":{},{}",
3556            internal_prefixes::DATA_V1,
3557            DATA_SYNC_V1,
3558            &json[1..]
3559        )
3560    }
3561
3562    fn wire(vectors: &serde_json::Value, name: &str) -> String {
3563        vectors["frames"]
3564            .as_array()
3565            .expect("frames")
3566            .iter()
3567            .find(|frame| frame["name"] == name)
3568            .unwrap_or_else(|| panic!("no vector named {name}"))["wire"]
3569            .as_str()
3570            .expect("wire is a string")
3571            .to_string()
3572    }
3573
3574    #[test]
3575    fn the_vector_file_is_the_size_it_was() {
3576        // A file silently shortened by a bad merge would make every
3577        // assertion below pass by not running.
3578        //
3579        // Every array, not only the frames. The rest are read by `for`
3580        // loops and by index, and a loop over an emptied array is the same
3581        // green test as a loop over a full one. Counting them here is what
3582        // makes "the vectors pass" mean the vectors ran.
3583        let vectors = vectors();
3584        let len = |name: &str| -> usize {
3585            vectors[name]
3586                .as_array()
3587                .unwrap_or_else(|| panic!("{name} must be an array"))
3588                .len()
3589        };
3590        assert_eq!(len("frames"), 12);
3591        assert_eq!(len("parse_defaults"), 3);
3592        assert_eq!(len("attachment_hashes"), 3);
3593        assert_eq!(len("attachment_references"), 2);
3594        assert_eq!(len("data_purposes"), 2);
3595        assert_eq!(len("blob_digests"), 1);
3596        assert_eq!(vectors["version"].as_u64(), Some(DATA_SYNC_V1 as u64));
3597        assert_eq!(vectors["prefix"].as_str(), Some(internal_prefixes::DATA_V1));
3598    }
3599
3600    #[test]
3601    fn every_frame_kind_encodes_to_its_vector() {
3602        let vectors = vectors();
3603
3604        let mut docs = BTreeMap::new();
3605        docs.insert("notes".to_string(), "AQID".to_string());
3606        assert_eq!(
3607            framed(&SyncBody::Versions {
3608                reply: false,
3609                partial: false,
3610                docs,
3611                gone: BTreeMap::new(),
3612                want: None,
3613            }),
3614            wire(&vectors, "version offer, complete, asking")
3615        );
3616
3617        // Inserted out of order on purpose: the map is ordered, so the wire
3618        // does not depend on what order a sender happened to hold them in.
3619        let mut docs = BTreeMap::new();
3620        docs.insert("b".to_string(), "Zg==".to_string());
3621        docs.insert("a".to_string(), String::new());
3622        assert_eq!(
3623            framed(&SyncBody::Versions {
3624                reply: true,
3625                partial: true,
3626                docs,
3627                gone: BTreeMap::new(),
3628                want: None,
3629            }),
3630            wire(&vectors, "version offer, answering leg")
3631        );
3632
3633        assert_eq!(
3634            framed(&SyncBody::Versions {
3635                reply: false,
3636                partial: false,
3637                docs: BTreeMap::new(),
3638                gone: BTreeMap::new(),
3639                want: None,
3640            }),
3641            wire(&vectors, "version offer with no documents")
3642        );
3643
3644        let mut docs = BTreeMap::new();
3645        docs.insert("kept".to_string(), "AQID".to_string());
3646        let mut gone = BTreeMap::new();
3647        gone.insert("removed".to_string(), "BAUG".to_string());
3648        assert_eq!(
3649            framed(&SyncBody::Versions {
3650                reply: false,
3651                partial: false,
3652                docs,
3653                gone,
3654                want: None,
3655            }),
3656            wire(&vectors, "version offer carrying a removal")
3657        );
3658
3659        let mut docs = BTreeMap::new();
3660        docs.insert("notes".to_string(), "AQID".to_string());
3661        assert_eq!(
3662            framed(&SyncBody::Versions {
3663                reply: false,
3664                partial: false,
3665                docs,
3666                gone: BTreeMap::new(),
3667                want: Some(vec!["notes".to_string(), "draft*".to_string()]),
3668            }),
3669            wire(&vectors, "version offer declaring an interest")
3670        );
3671
3672        let blob = BASE64.encode([0u8, 1, 2, 3, 4, 5, 6, 7]);
3673        assert_eq!(
3674            framed(&SyncBody::Delta {
3675                doc: "notes".to_string(),
3676                blob: blob.clone(),
3677            }),
3678            wire(&vectors, "delta")
3679        );
3680        assert_eq!(
3681            framed(&SyncBody::Snapshot {
3682                doc: "notes".to_string(),
3683                blob,
3684            }),
3685            wire(&vectors, "snapshot")
3686        );
3687        assert_eq!(
3688            framed(&SyncBody::NeedSnapshot {
3689                doc: "notes".to_string(),
3690            }),
3691            wire(&vectors, "snapshot request")
3692        );
3693
3694        let hash = vectors["attachment_hashes"][0]["sha256"]
3695            .as_str()
3696            .expect("hash")
3697            .to_string();
3698        assert_eq!(
3699            framed(&SyncBody::NeedBlob {
3700                hash: hash.clone(),
3701                to: None,
3702            }),
3703            wire(&vectors, "blob request")
3704        );
3705        assert_eq!(
3706            framed(&SyncBody::BlobGone { hash: hash.clone() }),
3707            wire(&vectors, "blob refusal")
3708        );
3709
3710        assert_eq!(
3711            framed(&SyncBody::NeedBlob {
3712                hash: hash.clone(),
3713                to: Some("off1qy60a3pu0l9tnthnk08c4w59teq7u6w28gjwg8zk".to_string()),
3714            }),
3715            wire(&vectors, "blob request addressed to one member")
3716        );
3717
3718        assert_eq!(
3719            framed(&SyncBody::Chunk {
3720                hash,
3721                i: 0,
3722                n: 2,
3723                blob: BASE64.encode([0u8, 1, 2, 3]),
3724            }),
3725            wire(&vectors, "blob chunk")
3726        );
3727    }
3728
3729    #[test]
3730    fn every_vector_parses_back_into_the_kind_it_names() {
3731        // The other direction, and the one a second implementation's sender
3732        // is judged by. Encoding-only vectors would let a decoder drift.
3733        let vectors = vectors();
3734        for frame in vectors["frames"].as_array().expect("frames") {
3735            let wire = frame["wire"].as_str().expect("wire");
3736            let body = wire
3737                .strip_prefix(internal_prefixes::DATA_V1)
3738                .expect("every vector carries the marker");
3739            let value: serde_json::Value = serde_json::from_str(body).expect("the body is JSON");
3740            assert_eq!(
3741                value["v"].as_u64(),
3742                Some(DATA_SYNC_V1 as u64),
3743                "{}: version",
3744                frame["name"]
3745            );
3746            assert_eq!(
3747                value["k"].as_str(),
3748                frame["kind"].as_str(),
3749                "{}: kind",
3750                frame["name"]
3751            );
3752            let parsed: SyncBody =
3753                serde_json::from_value(value).expect("the body parses into a frame");
3754            let kind = match parsed {
3755                SyncBody::Versions { .. } => "vv",
3756                SyncBody::Delta { .. } => "delta",
3757                SyncBody::Snapshot { .. } => "snap",
3758                SyncBody::NeedSnapshot { .. } => "need_snap",
3759                SyncBody::NeedBlob { .. } => "blob",
3760                SyncBody::BlobGone { .. } => "gone",
3761                SyncBody::Chunk { .. } => "chunk",
3762            };
3763            let expected = match frame["kind"].as_str().expect("kind") {
3764                "need_blob" => "blob",
3765                "blob_gone" => "gone",
3766                other => other,
3767            };
3768            assert_eq!(kind, expected, "{}: round trip", frame["name"]);
3769        }
3770    }
3771
3772    #[test]
3773    fn absent_flags_parse_as_false() {
3774        let vectors = vectors();
3775        for case in vectors["parse_defaults"]
3776            .as_array()
3777            .expect("parse_defaults")
3778        {
3779            let value: serde_json::Value =
3780                serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
3781            let SyncBody::Versions { reply, partial, .. } =
3782                serde_json::from_value(value).expect("parses")
3783            else {
3784                panic!("{}: expected a version offer", case["name"]);
3785            };
3786            assert_eq!(reply, case["expect"]["reply"].as_bool().expect("reply"));
3787            assert_eq!(
3788                partial,
3789                case["expect"]["partial"].as_bool().expect("partial")
3790            );
3791        }
3792    }
3793
3794    #[test]
3795    fn an_absent_interest_parses_as_asking_for_everything() {
3796        // Absent and empty are opposite answers, and the frozen cases pin
3797        // the one a build that predates the field sends.
3798        let vectors = vectors();
3799        for case in vectors["parse_defaults"]
3800            .as_array()
3801            .expect("parse_defaults")
3802        {
3803            let value: serde_json::Value =
3804                serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
3805            let SyncBody::Versions { want, .. } = serde_json::from_value(value).expect("parses")
3806            else {
3807                panic!("{}: expected a version offer", case["name"]);
3808            };
3809            assert!(
3810                case["expect"]["want"].is_null(),
3811                "{}: this case is about an absent interest",
3812                case["name"]
3813            );
3814            assert!(want.is_none(), "{}", case["name"]);
3815        }
3816    }
3817
3818    #[test]
3819    fn an_empty_interest_is_not_an_absent_one() {
3820        // The distinction the `Option` exists for: collapsing them would
3821        // make "send me nothing" read as "send me everything".
3822        let asks_for_nothing = serde_json::to_string(&SyncBody::Versions {
3823            reply: false,
3824            partial: false,
3825            docs: BTreeMap::new(),
3826            gone: BTreeMap::new(),
3827            want: Some(Vec::new()),
3828        })
3829        .unwrap();
3830        assert!(asks_for_nothing.contains(r#""want":[]"#));
3831
3832        let parsed: SyncBody = serde_json::from_str(&asks_for_nothing).unwrap();
3833        let SyncBody::Versions { want, .. } = parsed else {
3834            panic!("expected a version offer");
3835        };
3836        assert_eq!(want, Some(Vec::new()));
3837    }
3838
3839    #[test]
3840    fn an_absent_removal_map_parses_as_removing_nothing() {
3841        let vectors = vectors();
3842        for case in vectors["parse_defaults"]
3843            .as_array()
3844            .expect("parse_defaults")
3845        {
3846            let value: serde_json::Value =
3847                serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
3848            let SyncBody::Versions { gone, .. } = serde_json::from_value(value).expect("parses")
3849            else {
3850                panic!("{}: expected a version offer", case["name"]);
3851            };
3852            let expected = case["expect"]["gone"].as_object().expect("gone");
3853            assert_eq!(gone.len(), expected.len(), "{}", case["name"]);
3854        }
3855    }
3856
3857    #[test]
3858    fn attachment_hashes_match_their_vectors() {
3859        let vectors = vectors();
3860        for case in vectors["attachment_hashes"]
3861            .as_array()
3862            .expect("attachment_hashes")
3863        {
3864            let bytes = match (case.get("input_utf8"), case.get("input_hex")) {
3865                (Some(text), _) => text.as_str().expect("utf8").as_bytes().to_vec(),
3866                (_, Some(hex)) => {
3867                    let hex = hex.as_str().expect("hex");
3868                    (0..hex.len())
3869                        .step_by(2)
3870                        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
3871                        .collect()
3872                }
3873                _ => panic!("a hash vector needs an input"),
3874            };
3875            assert_eq!(
3876                bytes.len() as u64,
3877                case["size"].as_u64().expect("size"),
3878                "the vector's own size must describe its own input"
3879            );
3880            assert_eq!(
3881                OfflineProtocol::data_attachment_hash(&bytes),
3882                case["sha256"].as_str().expect("sha256").to_string()
3883            );
3884        }
3885    }
3886
3887    #[test]
3888    fn attachment_references_encode_to_their_vectors() {
3889        use offline_protocol_data::DataValue;
3890        let vectors = vectors();
3891        let hash = vectors["attachment_hashes"][0]["sha256"]
3892            .as_str()
3893            .expect("hash")
3894            .to_string();
3895        let size = vectors["attachment_hashes"][0]["size"]
3896            .as_u64()
3897            .expect("size");
3898
3899        let full = DataValue::Attachment {
3900            hash: hash.clone(),
3901            size,
3902            name: Some("plan.pdf".to_string()),
3903            mime: Some("application/pdf".to_string()),
3904        };
3905        assert_eq!(
3906            serde_json::to_string(&full).expect("encode"),
3907            vectors["attachment_references"][0]["json"]
3908                .as_str()
3909                .expect("json")
3910        );
3911
3912        // Absent, not null. A reader that has to tell one from the other has
3913        // been given two spellings of one value.
3914        let bare = DataValue::attachment(hash, size);
3915        assert_eq!(
3916            serde_json::to_string(&bare).expect("encode"),
3917            vectors["attachment_references"][1]["json"]
3918                .as_str()
3919                .expect("json")
3920        );
3921        assert!(!vectors["attachment_references"][1]["json"]
3922            .as_str()
3923            .expect("json")
3924            .contains("null"));
3925    }
3926
3927    #[test]
3928    fn data_purposes_encode_to_their_vectors() {
3929        let vectors = vectors();
3930        let hash = vectors["attachment_hashes"][0]["sha256"]
3931            .as_str()
3932            .expect("hash")
3933            .to_string();
3934        assert_eq!(
3935            serde_json::to_string(&DataPurpose::Attachment { hash }).expect("encode"),
3936            vectors["data_purposes"][0]["json"].as_str().expect("json")
3937        );
3938        assert_eq!(
3939            serde_json::to_string(&DataPurpose::Snapshot {
3940                doc: "notes".to_string(),
3941            })
3942            .expect("encode"),
3943            vectors["data_purposes"][1]["json"].as_str().expect("json")
3944        );
3945    }
3946
3947    #[test]
3948    fn blob_digests_match_their_vectors() {
3949        let vectors = vectors();
3950        for case in vectors["blob_digests"].as_array().expect("blob_digests") {
3951            let hex = case["input_hex"].as_str().expect("hex");
3952            let bytes: Vec<u8> = (0..hex.len())
3953                .step_by(2)
3954                .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
3955                .collect();
3956            assert_eq!(
3957                blob_digest(&bytes),
3958                case["digest"].as_str().expect("digest")
3959            );
3960        }
3961    }
3962}
3963
3964impl SyncBody {
3965    /// The custody class of this frame, or `None` for a kind custody never
3966    /// carries (`docs/spec/custody.md`, "The classes").
3967    ///
3968    /// Exhaustive on purpose: a new kind has to decide here whether a copy of
3969    /// it is idempotent in cost, rather than inheriting an answer.
3970    pub(crate) fn custody_class(&self) -> Option<&'static str> {
3971        match self {
3972            SyncBody::Versions { .. }
3973            | SyncBody::Delta { .. }
3974            | SyncBody::Snapshot { .. }
3975            | SyncBody::BlobGone { .. } => Some(crate::protocol::custody::CUSTODY_CLASS_DATA),
3976            // Not idempotent in cost: each acted-on copy of a request spends a
3977            // whole media transfer or a snapshot export, and a carried chunk
3978            // repeats an answer the requester already had.
3979            SyncBody::NeedSnapshot { .. } | SyncBody::NeedBlob { .. } | SyncBody::Chunk { .. } => {
3980                None
3981            }
3982        }
3983    }
3984}
3985
3986/// The custody class of a `__DATA_V1__` plaintext the depositor retains for
3987/// re-sealing, or `None` when it is not a sync frame this build reads, or is a
3988/// kind custody refuses by name.
3989///
3990/// Read the way an arriving frame is read: version before body, so a frame of
3991/// a future version is simply not deposited rather than misclassified.
3992pub(crate) fn custody_class_of_plaintext(plaintext: &str) -> Option<&'static str> {
3993    let body = plaintext.strip_prefix(internal_prefixes::DATA_V1)?;
3994    let value: serde_json::Value = serde_json::from_str(body).ok()?;
3995    if value.get("v").and_then(serde_json::Value::as_u64) != Some(u64::from(DATA_SYNC_V1)) {
3996        return None;
3997    }
3998    serde_json::from_value::<SyncBody>(value)
3999        .ok()?
4000        .custody_class()
4001}
4002
4003#[cfg(test)]
4004mod custody_class_tests {
4005    use super::*;
4006
4007    fn framed(body: &str) -> String {
4008        format!(
4009            "{}{{\"v\":{},{}",
4010            internal_prefixes::DATA_V1,
4011            DATA_SYNC_V1,
4012            &body[1..]
4013        )
4014    }
4015
4016    #[test]
4017    fn class_a_kinds_are_deposited_and_the_rest_are_not() {
4018        for (kind, expected) in [
4019            (
4020                "{\"k\":\"delta\",\"doc\":\"d\",\"blob\":\"AA==\"}",
4021                Some("data"),
4022            ),
4023            (
4024                "{\"k\":\"snap\",\"doc\":\"d\",\"blob\":\"AA==\"}",
4025                Some("data"),
4026            ),
4027            ("{\"k\":\"vv\",\"docs\":{}}", Some("data")),
4028            ("{\"k\":\"blob_gone\",\"hash\":\"h\"}", Some("data")),
4029            ("{\"k\":\"need_snap\",\"doc\":\"d\"}", None),
4030            ("{\"k\":\"need_blob\",\"hash\":\"h\"}", None),
4031            (
4032                "{\"k\":\"chunk\",\"hash\":\"h\",\"i\":0,\"n\":1,\"blob\":\"AA==\"}",
4033                None,
4034            ),
4035        ] {
4036            assert_eq!(
4037                custody_class_of_plaintext(&framed(kind)),
4038                expected,
4039                "{kind}"
4040            );
4041        }
4042    }
4043
4044    #[test]
4045    fn anything_but_a_readable_sync_frame_is_not_deposited() {
4046        assert_eq!(custody_class_of_plaintext("hello"), None);
4047        assert_eq!(
4048            custody_class_of_plaintext(&format!("{}not json", internal_prefixes::DATA_V1)),
4049            None
4050        );
4051        // A future version is not deposited rather than misclassified.
4052        let future = format!(
4053            "{}{{\"v\":{},\"k\":\"delta\",\"doc\":\"d\",\"blob\":\"AA==\"}}",
4054            internal_prefixes::DATA_V1,
4055            DATA_SYNC_V1 + 1
4056        );
4057        assert_eq!(custody_class_of_plaintext(&future), None);
4058    }
4059}