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