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//! One outbound frame is not an answer and does not count against this: a
47//! blob arriving for a document with local edits still pending flushes them
48//! first, which pushes them. That is work this device already owed the peer
49//! and the frame merely reached it, so it cannot recur: what it sends is
50//! what was pending before the frame arrived, and flushing is what stops it
51//! being pending. When that flush fails and the import then applies, the
52//! edit may have been folded into the imported change and suppressed with
53//! it, so a version offer is sent instead of the delta. That one is not an
54//! answer either, and it cannot recur for the same reason: it costs a
55//! storage failure that recovered inside a single frame.
56//!
57//! # Remote bytes are not our bytes
58//!
59//! Everywhere else in the data layer, the argument for handing bytes to the
60//! document engine is that they came back out of a sealed record whose AEAD
61//! tag already vouched for them. That argument does not survive contact with
62//! a network. MLS says who sent a blob; it says nothing about its shape, and
63//! the engine has an open defect where a malformed one aborts the process
64//! rather than returning an error. So blobs arriving here are judged before
65//! the engine sees them, and the one that gets through is remembered on disk
66//! until it has survived, so a blob that kills the process kills it once
67//! rather than every time the sender retries.
68
69use std::collections::BTreeMap;
70use std::time::{Duration, Instant};
71
72use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
73use offline_protocol_data::{CatchUp, RemoteImport, VersionToken};
74use serde::{Deserialize, Serialize};
75use sha2::{Digest, Sha256};
76use tracing::{debug, warn};
77
78use crate::error::{Error, Result};
79use crate::events::Event;
80use crate::media_envelope::DataPurpose;
81use crate::protocol::prefixes::internal_prefixes;
82use crate::protocol::types::{storage_keys, MediaSendOptions, DATA_SYNC_V1};
83use crate::protocol::OfflineProtocol;
84use offline_protocol_core::ContentType;
85
86/// Largest document blob carried inside one sync frame, before base64.
87///
88/// Sized to the mesh rather than to the record store. An unnegotiated
89/// Bluetooth link fragments a message into at most 512 pieces of 139 usable
90/// bytes, so roughly 69 KiB is the hard ceiling for a single message on the
91/// worst transport we ship; 32 KiB leaves room for base64 expansion, the
92/// frame's own JSON, the sealed envelope and the message header, and still
93/// arrives in one piece. Anything larger is not refused forever, it is
94/// answered by a snapshot offer that the media path will carry.
95pub(crate) const MAX_SYNC_BLOB_BYTES: usize = 32 * 1024;
96
97/// Documents listed in one version frame.
98///
99/// A name is capped at 128 bytes and an encoded version is short, so this
100/// keeps a version frame comfortably inside [`MAX_SYNC_BLOB_BYTES`] without
101/// having to measure it. A space with more documents than this sends
102/// several frames, which converges identically: the exchange is per
103/// document, not per frame.
104const MAX_DOCS_PER_VERSION_FRAME: usize = 128;
105
106/// How long an offer to one peer suppresses the next one.
107///
108/// Sized to discovery flapping rather than to editing: a change made locally
109/// is pushed the moment it is durable and does not wait for this, so the only
110/// thing the window delays is the reconciliation sweep, and the next trigger
111/// repeats it anyway.
112pub(crate) const DATA_SYNC_OFFER_INTERVAL: Duration = Duration::from_secs(30);
113
114/// Documents one space may hold on a peer's say-so.
115///
116/// Every name in an offer that this device does not recognise becomes a
117/// stored document, and nothing else bounds how many names one exchange can
118/// carry. The ceiling is far above what two people sharing documents reach
119/// and far below what a peer streaming fresh names could otherwise spend, so
120/// it is a bound on abuse rather than a product limit. It applies only to
121/// documents a peer names: an application creating its own is not restricted
122/// by it, and those still replicate outward.
123pub(crate) const MAX_DOCS_PER_SPACE: usize = 1024;
124
125/// Blob digests remembered per space after a crash.
126///
127/// Small on purpose. The list only has to outlive the sender's retries of
128/// one blob, and every entry in it is a document change we have decided
129/// never to apply, so a long list is a liability rather than a safety net.
130pub(crate) const MAX_QUARANTINED_BLOBS: usize = 32;
131
132/// Largest document encoding this device will carry over the media path.
133///
134/// The record ceiling, because that is the real limit: a document larger
135/// than one sealed protocol-state record cannot be persisted by the receiver
136/// even if every byte arrives, so carrying it would spend a long transfer to
137/// fail at the end. Well above the 1 MiB compacted cap a document is held
138/// to, which leaves room for the history a raw snapshot carries.
139pub(crate) const MAX_MEDIA_SNAPSHOT_BYTES: usize = super::types::MAX_PROTOCOL_STATE_RECORD_BYTES;
140
141/// Attachment fetches one device may have outstanding at once.
142///
143/// Bounded because each entry is created by a local call and cleared by a
144/// remote answer that may never come. Passing the bound evicts the oldest:
145/// a fetch that has been waiting longer than sixty-three others is not the
146/// one still worth remembering.
147pub(crate) const MAX_PENDING_ATTACHMENT_FETCHES: usize = 64;
148
149/// How long a fetch stays outstanding with no sign of an answer.
150///
151/// Measured from the last sign of life rather than from the question, and
152/// the difference is the whole of it: chunks of the answer refresh the
153/// entry, so a blob slower to arrive than this does not have its own record
154/// expire underneath it. That record is what admits the bytes at the end, so
155/// an expiry mid-carriage discards a blob that fully arrived, and the retry
156/// it invites takes just as long and dies exactly the same way.
157///
158/// Generous even so, because a holder may take a while to answer at all and
159/// the media path may be Bluetooth. The cost of being slow here is a
160/// reference that reports unavailable while somebody is still deciding, and
161/// the app can ask again; the cost of no timeout at all is a map that only
162/// grows.
163pub(crate) const ATTACHMENT_FETCH_TIMEOUT: Duration = Duration::from_secs(15 * 60);
164
165/// Blob requests one peer may have acted on inside a window.
166///
167/// A budget rather than only a per-hash window, because the two bound
168/// different things and only one of them is a hash we have seen before. The
169/// per-hash window stops a peer re-asking for the same blob; nothing in it
170/// stops a peer asking for a different one every time, and each of those
171/// costs a durable map entry and an application callback. A hash is 32 bytes
172/// the sender chooses, so without a budget the key space is the whole output
173/// of SHA-256 and the cost of exhausting this device's memory is one frame
174/// per entry.
175pub(crate) const MAX_BLOB_REQUESTS_PER_WINDOW: usize = 32;
176
177/// Blob-request windows remembered across all peers.
178///
179/// The hard bound under the per-peer budget above, and the one that holds
180/// when a peer's budget resets. Oldest out first: a window that has been
181/// held longer than 255 others has almost certainly expired anyway.
182pub(crate) const MAX_BLOB_REQUEST_WINDOWS: usize = 256;
183
184/// One sync frame's body, inside the decrypted MLS plaintext.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(tag = "k")]
187enum SyncBody {
188    /// "Here is what I have; send me what I am missing."
189    ///
190    /// `reply` marks the answering leg, which is what stops the exchange:
191    /// an offer provokes an answer, and an answer provokes nothing.
192    #[serde(rename = "vv")]
193    Versions {
194        #[serde(default)]
195        reply: bool,
196        /// Whether this frame carries less than everything the sender holds:
197        /// one frame of an offer split across several, or a single document
198        /// named because something about it needs answering.
199        ///
200        /// It exists because a receiver reads a document's *absence* from an
201        /// offer as "they have never seen this", and answers with the whole
202        /// document. That inference needs the complete list. Drawn from one
203        /// frame of a split offer it fires on every document the frame did
204        /// not happen to carry, so a peer holding more documents than one
205        /// frame fits would be sent the entire space, in full, on every
206        /// exchange, while perfectly in sync.
207        #[serde(default)]
208        partial: bool,
209        /// Document name to base64 of its version token.
210        docs: BTreeMap<String, String>,
211    },
212    /// A run of changes, base64.
213    #[serde(rename = "delta")]
214    Delta { doc: String, blob: String },
215    /// A whole document, base64. The answer to a gap no run of changes can
216    /// express.
217    #[serde(rename = "snap")]
218    Snapshot { doc: String, blob: String },
219    /// "No run of changes can close my gap in this document; send the whole
220    /// thing."
221    ///
222    /// The frame that makes a refusal recoverable. Without it a receiver
223    /// that declines a delta for reaching below its trim point has no way to
224    /// say what would work: answering with a version offer instead asks the
225    /// sender to compute changes since our version, which is the same
226    /// refused delta again, for as long as both sides keep at it.
227    #[serde(rename = "need_snap")]
228    NeedSnapshot { doc: String },
229    /// "I hold a reference to these bytes and not the bytes; do you have
230    /// them?"
231    ///
232    /// Carries no document name. A blob is addressed by what it is rather
233    /// than by where a reference to it happens to sit, so the same bytes
234    /// referenced from three documents are one blob and one request.
235    #[serde(rename = "need_blob")]
236    NeedBlob { hash: String },
237    /// "I do not have those bytes, and asking again will not change that."
238    ///
239    /// The frame that lets a fetch end. An attachment reference outlives the
240    /// bytes it names, because the reference replicates and the bytes do
241    /// not, so a peer holding a reference and no blob is ordinary rather
242    /// than broken. Without an answer for it the asking side cannot tell
243    /// that case from a peer that is merely slow, and shows a spinner
244    /// forever.
245    #[serde(rename = "blob_gone")]
246    BlobGone { hash: String },
247}
248
249/// Where a sync frame goes and what it is sealed under.
250///
251/// The one thing F4 added to this module. 1:1 replication could treat the
252/// space, the peer, and the reply address as a single value because they
253/// genuinely were one; a group space is one scope with many members, so the
254/// space no longer names the recipient and the two have to be carried
255/// separately.
256#[derive(Debug, Clone)]
257pub(crate) enum SyncChannel {
258    /// A 1:1 space, sealed to the peer the space is named after.
259    Peer,
260    /// A group space, sealed once for the whole roster.
261    ///
262    /// Used only for changes every member needs: a local commit. Group MLS
263    /// produces one ciphertext for everyone, which is what makes group
264    /// replication cheap, and it is also why this must never be used for an
265    /// answer to one member's question — the other members would each
266    /// receive, decrypt, and import a blob they already had.
267    GroupBroadcast,
268    /// A group space, sealed for the roster but delivered to one member.
269    ///
270    /// The reply leg. Anti-entropy between two members of a group is still
271    /// a conversation between two devices: an offer is answered by the
272    /// member that received it and by nobody else. Every other member is
273    /// spared the traffic, and the terminating 1.5-round-trip exchange the
274    /// 1:1 path relies on keeps working unchanged.
275    ///
276    /// Sealed under the group key rather than the pair's 1:1 session on
277    /// purpose: two members of a group need not have a 1:1 session at all,
278    /// and requiring one would make replication depend on a handshake that
279    /// may never happen.
280    GroupDirected(String),
281}
282
283/// Which frame a blob arrived in.
284///
285/// Carried into the import so a rung of the catch-up ladder knows whether
286/// there is a rung above it. A delta that cannot apply has one; a snapshot
287/// is already the top, and asking again returns the same bytes.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289enum BlobKind {
290    Delta,
291    Snapshot,
292}
293
294/// Per-space replication bookkeeping.
295///
296/// Everything here is about surviving a crash. Nothing in it is needed for
297/// convergence, which is deliberate: the version exchange is the recovery
298/// mechanism, so this record can be lost without costing a change.
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300struct SyncRecord {
301    /// The blob currently being handed to the document engine.
302    ///
303    /// Written before the engine is called and cleared after it returns. A
304    /// value still here at open means the last process to touch this space
305    /// did not survive that call.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    in_flight: Option<String>,
308    /// Blobs that were in flight when a previous run died, newest last.
309    #[serde(default, skip_serializing_if = "Vec::is_empty")]
310    quarantined: Vec<String>,
311}
312
313/// The digest a blob is remembered by.
314pub(crate) fn blob_digest(blob: &[u8]) -> String {
315    let mut hasher = Sha256::new();
316    hasher.update(blob);
317    // Half a SHA-256 is 128 bits, which is far past what a collision would
318    // need to be worth engineering: the prize is having one specific blob
319    // refused on one device.
320    hex_encode(&hasher.finalize()[..16])
321}
322
323/// The rate-limit key for offers toward one member of one group.
324///
325/// A group space and a 1:1 space share one window map, so the two key
326/// spaces must not overlap: a bare peer address is the 1:1 key, and this
327/// one is deliberately built with a character no validated peer id, group
328/// id or address can contain, so no composed key can ever collide with a
329/// peer's own.
330fn group_offer_key(member: &str, group_id: &str) -> String {
331    format!("{member}{GROUP_OFFER_KEY_SEP}{group_id}")
332}
333
334/// The separator [`group_offer_key`] composes with, named so the split that
335/// takes a key back apart cannot drift from the format that built it.
336const GROUP_OFFER_KEY_SEP: char = '\x01';
337
338fn hex_encode(bytes: &[u8]) -> String {
339    bytes.iter().map(|b| format!("{b:02x}")).collect()
340}
341
342impl OfflineProtocol {
343    // ---- outbound ---------------------------------------------------
344
345    /// Offer our versions of every document shared with `peer`.
346    ///
347    /// The entry point for all three triggers (session confirm, reconnect,
348    /// startup). Cheap and safe to call spuriously: a peer that is already
349    /// current answers with nothing.
350    pub(crate) fn kick_data_sync(&mut self, peer: &str, cause: &str) {
351        if !self.data_sync_active(peer) {
352            return;
353        }
354        if !self.data_sync_offer_due(peer) {
355            return;
356        }
357        self.offer_versions(peer, cause);
358    }
359
360    /// Offer our versions of every document in `group_id` to one member.
361    ///
362    /// The group counterpart of [`Self::kick_data_sync`], and addressed for
363    /// the same reason its answers are: a member coming back into range is
364    /// a conversation with that member. Broadcasting the offer to the whole
365    /// roster would have every other member answer a question nobody asked
366    /// them.
367    ///
368    /// Rate-limited per (member, group) rather than per member, so a peer
369    /// shared across several groups reconciles all of them rather than
370    /// whichever one its rediscovery happened to reach first.
371    #[cfg_attr(not(feature = "data"), allow(dead_code))]
372    pub(crate) fn kick_group_data_sync(&mut self, group_id: &str, member: &str, cause: &str) {
373        let members = match self.group_roster(group_id) {
374            Ok(members) => members,
375            Err(_) => return,
376        };
377        if !self.group_data_sync_active(&members) {
378            // Rediscovery is the one trigger a group space has that does
379            // not require somebody to be editing, so it is also the only
380            // moment a device that never writes anything will notice the
381            // gate is shut. Probing only from the local-commit path would
382            // leave a member that merely reads waiting for someone else to
383            // do it.
384            self.probe_group_data_capabilities(group_id, &members);
385            return;
386        }
387        let key = group_offer_key(member, group_id);
388        if !self.data_sync_offer_due(&key) {
389            return;
390        }
391        self.offer_versions_over(
392            group_id,
393            &SyncChannel::GroupDirected(member.to_string()),
394            cause,
395        );
396    }
397
398    /// Ask the members holding a group's replication gate closed what they
399    /// support.
400    ///
401    /// For a group the usual reason the gate is shut is not that somebody
402    /// opted out but that we have never heard from them: members added by a
403    /// third party exchange no key packages with us, and an inviter running
404    /// an SDK from before attestation forwards none. Sending them ours
405    /// makes their automatic reply teach us what they support.
406    ///
407    /// The probe itself is capability-agnostic and guarded by
408    /// `key_package_sent_to`, so a group that stays closed does not re-probe
409    /// on every commit or every rediscovery.
410    #[cfg_attr(not(feature = "data"), allow(dead_code))]
411    fn probe_group_data_capabilities(&mut self, space: &str, members: &[String]) {
412        let unknown = self.group_data_unknown_members(members);
413        if unknown.is_empty() {
414            return;
415        }
416        debug!(
417            space,
418            unknown = unknown.len(),
419            "Group replication is held closed by members of unknown capability; probing"
420        );
421        self.backfill_group_rich_capabilities(&unknown);
422    }
423
424    /// Drop every offer window belonging to `peer`: the 1:1 one, and the
425    /// group one for each group shared with them.
426    ///
427    /// The group windows are keyed by (member, group), so removing the bare
428    /// peer key leaves them all behind, and each of them suppresses the
429    /// first group offer to that peer for a further window once the
430    /// capability is relearned. That is the same silent non-sync
431    /// [`Self::forget_data_sync_peer`] exists to prevent, one space at a
432    /// time.
433    ///
434    /// Windows left by a group this device *left* are deliberately not
435    /// swept here: they name a scope that no longer replicates at all, and
436    /// on a re-join the worst they can cost is one suppressed offer that
437    /// the next discovery re-sends.
438    #[cfg(feature = "data")]
439    pub(crate) fn forget_data_sync_offer_windows(&mut self, peer: &str) {
440        self.last_data_sync_offer.retain(|key, _| {
441            key != peer
442                && !key
443                    .split_once(GROUP_OFFER_KEY_SEP)
444                    .is_some_and(|(member, _)| member == peer)
445        });
446    }
447
448    /// Whether the reconciliation sweep keyed by `key` is outside its
449    /// suppression window, stamping it when it is.
450    ///
451    /// Stamped here rather than after the send for the reason
452    /// [`Self::offer_versions_over`] gives: a read that fails will still
453    /// fail a moment later, and retrying it at discovery speed is the
454    /// traffic the window exists to prevent.
455    fn data_sync_offer_due(&mut self, key: &str) -> bool {
456        if let Some(last) = self.last_data_sync_offer.get(key) {
457            if Instant::now().duration_since(*last) < DATA_SYNC_OFFER_INTERVAL {
458                return false;
459            }
460        }
461        self.last_data_sync_offer
462            .insert(key.to_string(), Instant::now());
463        true
464    }
465
466    /// Offer versions of every group space this device shares with `peer`.
467    ///
468    /// Wired to the same rediscovery seam as [`Self::kick_data_sync`], and
469    /// needed because a group space has no 1:1 trigger to ride: two members
470    /// may never establish a session with each other, so without this the
471    /// only thing that ever reconciles them is a fresh local commit.
472    ///
473    /// Which groups this device is in comes from MLS, not from the roster
474    /// cache alone. The cache is filled on demand and is empty after a
475    /// restart, so reading only it would make this sweep silently do nothing
476    /// on a cold process, which is the launch where it matters most: two
477    /// members that drifted apart and are not editing have no other trigger
478    /// left.
479    #[cfg_attr(not(feature = "data"), allow(dead_code))]
480    pub(crate) fn kick_shared_group_data_sync(&mut self, peer: &str, cause: &str) {
481        if !self.config.data.enabled {
482            return;
483        }
484        self.enumerate_group_spaces_once();
485        let shared: Vec<String> = self
486            .group_mesh
487            .members
488            .iter()
489            .filter(|(_, members)| members.iter().any(|m| m == peer))
490            .map(|(group_id, _)| group_id.clone())
491            .collect();
492        for group_id in shared {
493            self.kick_group_data_sync(&group_id, peer, cause);
494        }
495    }
496
497    /// Fill the roster cache from MLS once per session, so
498    /// [`Self::kick_shared_group_data_sync`] can see a group that nothing has
499    /// touched yet.
500    ///
501    /// Costs one key listing, plus a group-state read for each group not
502    /// already cached — the same read the next send to that group would have
503    /// paid. Marked done even when a group read fails: the failure is per
504    /// group and retrying the whole enumeration on every discovery is the
505    /// traffic the offer window exists to prevent. An MLS that is not ready
506    /// yet is not a failure of this kind and leaves the flag alone, so the
507    /// next discovery tries again.
508    fn enumerate_group_spaces_once(&mut self) {
509        if self.group_spaces_enumerated {
510            return;
511        }
512        let Ok(groups) = self.list_groups() else {
513            return;
514        };
515        self.group_spaces_enumerated = true;
516        for group_id in groups {
517            if self.group_mesh.members.contains_key(&group_id) {
518                continue;
519            }
520            if let Err(err) = self.refresh_group_members(&group_id) {
521                debug!(group_id = %group_id, error = %err, "Group roster unreadable while enumerating group spaces");
522            }
523        }
524    }
525
526    /// Offer our versions because a change happened that no frame carried,
527    /// without waiting for the rate limit.
528    ///
529    /// `origin` names the peer a change arrived from, so one applied from
530    /// the very peer this space replicates with is not announced back.
531    ///
532    /// The limiter damps discovery flapping, where the offer it suppresses
533    /// is a sweep the next trigger repeats anyway. Nothing repeats this one.
534    /// A change too large to inline left no trace on the wire at all, so
535    /// until somebody asks, both sides hold documents they believe agree;
536    /// on a link that never drops there is no next trigger to ask.
537    pub(crate) fn nudge_data_sync(&mut self, space: &str, origin: Option<&str>, cause: &str) {
538        match self.space_channel(space, origin) {
539            Some(SyncChannel::Peer) => self.offer_versions(space, cause),
540            // The roster-wide leg, and the one place a group offer is not
541            // addressed to one member. What went unsent was a change every
542            // member needs, and there is no single member to ask: each of
543            // them answers with what they are missing, and a member that is
544            // already current answers with nothing.
545            Some(channel @ SyncChannel::GroupBroadcast) => {
546                self.offer_versions_over(space, &channel, cause)
547            }
548            Some(SyncChannel::GroupDirected(_)) | None => {}
549        }
550    }
551
552    /// How a space replicates, or `None` when it does not.
553    ///
554    /// The single place the three kinds of space are told apart. A space is
555    /// a group space when MLS says this device holds a group by that name,
556    /// a 1:1 space when the name is a peer that advertised replication, and
557    /// local-only otherwise — and "local-only" is the honest answer for a
558    /// space whose peer never advertised, whose group is gone, or whose
559    /// members are not all group-capable.
560    ///
561    /// `origin`, when set, names the peer a change was just applied from,
562    /// and suppresses announcing that change back to them.
563    fn space_channel(&mut self, space: &str, origin: Option<&str>) -> Option<SyncChannel> {
564        if let Some(members) = self.group_space_roster(space) {
565            // A change that arrived over the group reached every member at
566            // once, because that is what one group ciphertext does. Pushing
567            // it on would have every member push every change to every
568            // other member: N times the frames for nothing, and worse as
569            // the group grows. Anti-entropy still closes real gaps.
570            if origin.is_some() {
571                return None;
572            }
573            if self.group_data_sync_active(&members) {
574                return Some(SyncChannel::GroupBroadcast);
575            }
576            self.probe_group_data_capabilities(space, &members);
577            return None;
578        }
579        if origin == Some(space) || !self.data_sync_active(space) {
580            return None;
581        }
582        Some(SyncChannel::Peer)
583    }
584
585    /// Send our version of every document in a space to the peer that names
586    /// it, and start the window that suppresses the next sweep.
587    fn offer_versions(&mut self, peer: &str, cause: &str) {
588        // Stamped even on the unlimited path so an offer sent right now
589        // still suppresses the next sweep: the sweep would re-send what
590        // this frame already carried.
591        self.last_data_sync_offer
592            .insert(peer.to_string(), Instant::now());
593        self.offer_versions_over(peer, &SyncChannel::Peer, cause);
594    }
595
596    /// [`Self::offer_versions`] over an explicit channel, which is what a
597    /// group space needs: the offer is addressed to one member, not
598    /// broadcast to the roster.
599    fn offer_versions_over(&mut self, space: &str, channel: &SyncChannel, cause: &str) {
600        let peer = space;
601        let docs = match self.data_sync_versions(peer) {
602            Ok(docs) => docs,
603            Err(err) => {
604                // A whole space that cannot enumerate its documents stops
605                // replicating with this peer until the storage recovers, and
606                // nothing else reports it. The rate limit above is stamped
607                // before this read precisely so warning here cannot become
608                // discovery-speed noise.
609                warn!(peer = %peer, cause, error = %err, "Could not read document versions");
610                return;
611            }
612        };
613        debug!(space = %peer, cause, docs = docs.len(), "Offering document versions");
614        self.send_version_frames(peer, channel, docs, false, false);
615    }
616
617    /// Send one version frame per batch of documents.
618    ///
619    /// `force_partial` marks the frames as carrying less than everything we
620    /// hold even when they fit in one, which is what a question about
621    /// particular documents needs: without it the peer reads the names it
622    /// does not see as documents we have never seen, and answers a question
623    /// about one document with the whole space.
624    fn send_version_frames(
625        &mut self,
626        space: &str,
627        channel: &SyncChannel,
628        docs: BTreeMap<String, String>,
629        reply: bool,
630        force_partial: bool,
631    ) {
632        // A space with no documents still says so on the offering leg: that
633        // is what tells a peer holding documents to send them to a replica
634        // that has never seen any.
635        if docs.is_empty() && reply {
636            return;
637        }
638        let batches: Vec<BTreeMap<String, String>> = if docs.is_empty() {
639            vec![BTreeMap::new()]
640        } else {
641            docs.into_iter()
642                .collect::<Vec<_>>()
643                .chunks(MAX_DOCS_PER_VERSION_FRAME)
644                .map(|chunk| chunk.iter().cloned().collect())
645                .collect()
646        };
647        // Every frame of a split offer is partial, including the last: the
648        // inference that needs the complete list cannot be drawn from any
649        // one of them, and there is nowhere to accumulate them that a
650        // restart would not have to reconcile.
651        let partial = force_partial || batches.len() > 1;
652        for batch in batches {
653            self.send_sync_frame(
654                space,
655                channel,
656                &SyncBody::Versions {
657                    reply,
658                    partial,
659                    docs: batch,
660                },
661            );
662        }
663    }
664
665    /// Ask the peer for what we are missing in one document, without
666    /// starting an exchange.
667    ///
668    /// Both flags are load-bearing. `reply` collects the catch-up while
669    /// suppressing the counter-offer, so a request cannot start a chain of
670    /// its own; `partial` keeps the peer from reading one name as the
671    /// complete list and answering with every other document in the space.
672    fn request_doc_catch_up(&mut self, space: &str, channel: &SyncChannel, doc: &str) {
673        let version = match self.data_doc_version(space, doc) {
674            Ok(version) => version,
675            Err(err) => {
676                warn!(space, doc, error = %err, "Could not read our version to ask for the gap");
677                return;
678            }
679        };
680        self.send_version_frames(
681            space,
682            channel,
683            BTreeMap::from([(doc.to_string(), version)]),
684            true,
685            true,
686        );
687    }
688
689    /// Seal one frame and put it on the ladder.
690    ///
691    /// The channel decides what "seal" means: a 1:1 space seals to its
692    /// peer, a group space seals once under the group key. Everything
693    /// above this function is written in terms of a space and a channel and
694    /// does not know which of the two it is driving, which is what keeps
695    /// one implementation of the exchange, the catch-up ladder and the
696    /// import containment serving both.
697    fn send_sync_frame(&mut self, space: &str, channel: &SyncChannel, body: &SyncBody) {
698        let plaintext = match serde_json::to_string(body) {
699            Ok(json) => format!(
700                "{}{{\"v\":{},{}",
701                internal_prefixes::DATA_V1,
702                // The frame schema is the 1:1 version in both cases. The
703                // group entry advertises that a peer *intercepts* these
704                // frames inside a group ciphertext; it does not describe a
705                // second frame shape, and minting one would mean two
706                // parsers for identical bodies.
707                DATA_SYNC_V1,
708                // The body serializes as an object; splice the version in as
709                // its first field rather than nesting, so a future version
710                // can be read off a frame whose body shape it cannot parse.
711                &json[1..]
712            ),
713            Err(err) => {
714                warn!(space, error = %err, "Failed to encode sync frame");
715                return;
716            }
717        };
718
719        match channel {
720            SyncChannel::Peer => self.send_sync_frame_to_peer(space, plaintext),
721            SyncChannel::GroupBroadcast => {
722                if let Err(err) = self.send_group_internal_frame(space, None, &plaintext) {
723                    debug!(space, error = %err, "Group replication frame not sent");
724                }
725            }
726            SyncChannel::GroupDirected(member) => {
727                let member = member.clone();
728                if let Err(err) = self.send_group_internal_frame(space, Some(&member), &plaintext) {
729                    debug!(space, error = %err, "Directed replication frame not sent");
730                }
731            }
732        }
733    }
734
735    /// The 1:1 leg: seal to the peer the space is named after.
736    ///
737    /// Deliberately the strict encryptor: a sync frame never initiates
738    /// session establishment. Documents converge when the peers next talk,
739    /// and provoking a handshake for an offer nobody asked for would make
740    /// every reconnect noisier than the messaging it rides on.
741    fn send_sync_frame_to_peer(&mut self, peer: &str, plaintext: String) {
742        let encrypted = match self.encrypt_content_for_recipient_strict(peer, &plaintext) {
743            Ok(content) => content,
744            Err(err) => {
745                // No session yet: nothing is lost. The version exchange runs
746                // again on the next confirm, which is exactly the event that
747                // would make this succeed.
748                debug!(peer = %peer, error = %err, "Sync frame skipped: no session");
749                return;
750            }
751        };
752
753        let message = match self.create_message(peer, encrypted, None, None) {
754            Ok(message) => message,
755            Err(err) => {
756                warn!(peer = %peer, error = %err, "Failed to build sync frame");
757                return;
758            }
759        };
760
761        if self.deduplicator.is_duplicate(&message.id) {
762            return;
763        }
764        self.deduplicator.mark_seen_local(message.id.clone());
765
766        // Tier 2: keep the plaintext so a resend after a re-key seals against
767        // the peer's current epoch instead of replaying ciphertext they can
768        // no longer open. The analogous session-confirm sender does not do
769        // this and spends its whole retry budget on dead bytes; there is no
770        // reason to inherit that. Staged after the duplicate check so the
771        // entry belongs to a frame that is actually on the ladder.
772        self.stage_outbox_reseal(
773            &message.id,
774            crate::protocol::types::OutboxReseal {
775                content: plaintext,
776                priority: message.priority,
777                reply_to_msg: None,
778                forwarded_from: None,
779                content_type: message.content_type,
780                media_metadata: None,
781                rich: None,
782            },
783        );
784
785        let previous_transport = self.transport_manager.current_transport();
786        match self.transport_manager.send(&message) {
787            Ok(()) => {
788                let current = self.transport_manager.current_transport();
789                let _ = self.handle_send_success(&message, current);
790            }
791            Err(err) => {
792                let current = self.transport_manager.current_transport();
793                let _ = self.handle_send_failure(&message, current.or(previous_transport));
794                debug!(peer = %peer, error = %err, "Sync frame deferred to the outbox");
795            }
796        }
797    }
798
799    /// Push a freshly committed change to the peer this space replicates
800    /// with.
801    ///
802    /// `origin` names the peer a change arrived from, when it did. Echoing a
803    /// change back to whoever sent it is harmless — the merge absorbs it —
804    /// but on a mesh it is a frame nobody needed, and a commit produced by
805    /// applying a remote delta exports that delta again.
806    pub(crate) fn push_data_delta(
807        &mut self,
808        space: &str,
809        doc: &str,
810        blob: &[u8],
811        origin: Option<&str>,
812    ) {
813        let Some(channel) = self.space_channel(space, origin) else {
814            return;
815        };
816        if blob.len() > MAX_SYNC_BLOB_BYTES {
817            // Too big to inline, so the catch-up ladder has to fetch it: it
818            // can answer with a compacted snapshot instead of raw history.
819            // Offering now rather than waiting is what makes that happen. On
820            // a link that never drops, "the peer's next version offer" is
821            // not a time, and until it arrives both replicas believe they
822            // agree.
823            debug!(
824                space,
825                doc,
826                bytes = blob.len(),
827                "Change too large to push inline; offering versions instead"
828            );
829            self.nudge_data_sync(space, origin, "oversized_delta");
830            return;
831        }
832        self.send_sync_frame(
833            space,
834            &channel,
835            &SyncBody::Delta {
836                doc: doc.to_string(),
837                blob: BASE64.encode(blob),
838            },
839        );
840    }
841
842    // ---- inbound ----------------------------------------------------
843
844    /// Handle a `__DATA_V1__` frame that arrived sealed from `sender`.
845    ///
846    /// Every outcome is terminal. A frame is never deferred for a
847    /// data-layer reason: deferral means "this same ciphertext will succeed
848    /// once the session is ready" and nothing else, so using it for a
849    /// corrupt blob or a disabled layer would spend the sender's whole retry
850    /// budget on a frame that cannot ever be accepted.
851    pub(crate) fn handle_data_sync_frame(&mut self, sender: &str, body: &str) {
852        // The space is the sender. Never a field on the frame: a peer that
853        // could name the space could write into a document it replicates
854        // with somebody else.
855        self.handle_data_sync_frame_over(sender, &SyncChannel::Peer, sender, body);
856    }
857
858    /// A `__DATA_V1__` frame that arrived inside a *group* ciphertext.
859    ///
860    /// The space is the group, and the same structural argument holds as
861    /// for 1:1: the group is not named by the frame, it is the group whose
862    /// key opened the ciphertext. A member of one group cannot reach
863    /// another group's documents without being a member of that group too,
864    /// and the decrypt already proved membership and authenticated the
865    /// sender.
866    ///
867    /// Answers go back to `sender` alone rather than to the roster. Every
868    /// other member either has what was asked for or will ask for it
869    /// themselves.
870    #[cfg_attr(not(feature = "data"), allow(dead_code))]
871    pub(crate) fn handle_group_data_sync_frame(
872        &mut self,
873        group_id: &str,
874        sender: &str,
875        body: &str,
876    ) {
877        self.handle_data_sync_frame_over(
878            group_id,
879            &SyncChannel::GroupDirected(sender.to_string()),
880            sender,
881            body,
882        );
883    }
884
885    fn handle_data_sync_frame_over(
886        &mut self,
887        space: &str,
888        channel: &SyncChannel,
889        sender: &str,
890        body: &str,
891    ) {
892        if !self.config.data.enabled {
893            // Parsing is never capability-gated, but applying is: the layer
894            // is off, so the frame is dropped. Nothing is lost permanently —
895            // whatever it carried is still on the sender, and the version
896            // exchange after the layer comes back on asks for it again.
897            debug!(peer = %sender, "Sync frame dropped: the data layer is off");
898            return;
899        }
900
901        // The version is read before the body so a future frame shape is
902        // declined on its version rather than failing to parse. Reversing
903        // these two would make every future format look like corruption.
904        let value: serde_json::Value = match serde_json::from_str(body) {
905            Ok(value) => value,
906            Err(err) => {
907                warn!(peer = %sender, error = %err, "Malformed sync frame");
908                return;
909            }
910        };
911        match value.get("v").and_then(serde_json::Value::as_u64) {
912            Some(v) if v == DATA_SYNC_V1 as u64 => {}
913            other => {
914                debug!(peer = %sender, version = ?other, "Sync frame from an unsupported version");
915                return;
916            }
917        }
918        let frame: SyncBody = match serde_json::from_value(value) {
919            Ok(frame) => frame,
920            Err(err) => {
921                warn!(peer = %sender, error = %err, "Unreadable sync frame body");
922                return;
923            }
924        };
925
926        let space = space.to_string();
927        let outcome = match frame {
928            SyncBody::Versions {
929                reply,
930                partial,
931                docs,
932            } => self.answer_version_offer(&space, channel, reply, partial, docs),
933            SyncBody::Delta { doc, blob } => {
934                self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Delta)
935            }
936            SyncBody::Snapshot { doc, blob } => {
937                self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Snapshot)
938            }
939            SyncBody::NeedSnapshot { doc } => self.answer_snapshot_request(&space, channel, &doc),
940            SyncBody::NeedBlob { hash } => self.answer_blob_request(&space, channel, &hash),
941            SyncBody::BlobGone { hash } => self.report_blob_gone(channel, &space, &hash),
942        };
943        if let Err(err) = outcome {
944            warn!(peer = %sender, error = %err, "Sync frame could not be handled");
945        }
946    }
947
948    /// Answer a peer's version offer with what they are missing.
949    fn answer_version_offer(
950        &mut self,
951        space: &str,
952        channel: &SyncChannel,
953        reply: bool,
954        partial: bool,
955        theirs: BTreeMap<String, String>,
956    ) -> Result<()> {
957        let mut ours = self.data_sync_versions(space).unwrap_or_default();
958
959        // Documents they have and we do not. A created document is empty,
960        // and the only thing that ever fills it is this device asking for
961        // its contents, so what is created here has to be asked about
962        // before this frame is done with: the peer has already told us
963        // everything it intends to and will not volunteer it again.
964        let mut created = BTreeMap::new();
965        for doc in theirs.keys() {
966            if ours.contains_key(doc) || offline_protocol_data::validate_name(doc).is_err() {
967                continue;
968            }
969            // The same door a blob for an unknown document goes through,
970            // and the same ceiling. Counting the versions read above
971            // instead would undercount by every document whose version
972            // could not be read, which is how a space gets past its cap.
973            if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
974                warn!(
975                    space,
976                    cap = MAX_DOCS_PER_SPACE,
977                    "Space is at its document ceiling; ignoring the rest of the offer"
978                );
979                break;
980            }
981            if self.data_create_doc(space, doc).is_err() {
982                continue;
983            }
984            // Folded into `ours` rather than re-read from the whole space
985            // below: one version read per frame instead of two, and the
986            // answer then describes exactly the documents this leg judged.
987            match self.data_doc_version(space, doc) {
988                Ok(version) => {
989                    ours.insert(doc.clone(), version.clone());
990                    created.insert(doc.clone(), version);
991                }
992                Err(err) => {
993                    warn!(space, doc, error = %err, "Created a document from an offer but cannot read its version");
994                }
995            }
996        }
997
998        for (doc, ours_encoded) in &ours {
999            let Some(theirs_encoded) = theirs.get(doc) else {
1000                // They did not name this document. On a complete offer that
1001                // means they have never seen it and the whole document is
1002                // the answer. On a partial one it means nothing: the name
1003                // may be in a frame that has not arrived, or in no frame at
1004                // all because the offer was about a single document.
1005                if !partial {
1006                    self.offer_catch_up(space, channel, doc, None);
1007                }
1008                continue;
1009            };
1010            if theirs_encoded == ours_encoded {
1011                continue;
1012            }
1013            match BASE64.decode(theirs_encoded) {
1014                Ok(token) => {
1015                    self.offer_catch_up(space, channel, doc, Some(VersionToken::from_bytes(token)))
1016                }
1017                Err(err) => {
1018                    warn!(space, doc, error = %err, "Undecodable version token in a sync frame");
1019                }
1020            }
1021        }
1022
1023        // One answer, and the exchange is over. Answering an answer is how a
1024        // pair of replicas talk to each other forever.
1025        //
1026        // Either way the documents created above are asked about, which is
1027        // the invariant to keep: the counter-offer names them when there is
1028        // one, and when there is not they are asked for on their own. An
1029        // answering leg that skipped the question would leave a document
1030        // created from it empty for as long as the link stays up, because
1031        // the peer has said everything it had to say and nothing else here
1032        // ever asks.
1033        if !reply {
1034            self.send_version_frames(space, channel, ours, true, false);
1035        } else {
1036            self.send_version_frames(space, channel, created, true, true);
1037        }
1038        Ok(())
1039    }
1040
1041    /// Send `doc` to the peer in whatever form can carry it.
1042    ///
1043    /// The ladder, cheapest first: the changes since their version, a whole
1044    /// document, and then nothing. "Nothing" is a real rung and it is
1045    /// reported rather than retried: a document too large for every inline
1046    /// form needs the media path, which the attachment stage brings, and a
1047    /// silent stall would be indistinguishable from a peer with no changes.
1048    fn offer_catch_up(
1049        &mut self,
1050        space: &str,
1051        channel: &SyncChannel,
1052        doc: &str,
1053        theirs: Option<VersionToken>,
1054    ) {
1055        if let Some(token) = theirs {
1056            match self.data_catch_up(space, doc, &token) {
1057                Ok(CatchUp::UpToDate) => return,
1058                Ok(CatchUp::Updates(bytes)) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
1059                    self.send_sync_frame(
1060                        space,
1061                        channel,
1062                        &SyncBody::Delta {
1063                            doc: doc.to_string(),
1064                            blob: BASE64.encode(bytes),
1065                        },
1066                    );
1067                    return;
1068                }
1069                // Either too large to inline or a gap no run of changes can
1070                // express. Both are answered by the snapshot below.
1071                Ok(_) => {}
1072                Err(err) => {
1073                    warn!(space, doc, error = %err, "Could not compute catch-up");
1074                    return;
1075                }
1076            }
1077        }
1078
1079        self.send_snapshot(space, channel, doc);
1080    }
1081
1082    /// Send a whole document, the top rung and the answer to every refusal
1083    /// below it.
1084    ///
1085    /// Terminal by construction: a snapshot provokes no answer of its own,
1086    /// which is what lets the refusals underneath it ask for one freely.
1087    fn send_snapshot(&mut self, space: &str, channel: &SyncChannel, doc: &str) {
1088        match self.data_export_snapshot(space, doc) {
1089            Ok(bytes) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
1090                self.send_sync_frame(
1091                    space,
1092                    channel,
1093                    &SyncBody::Snapshot {
1094                        doc: doc.to_string(),
1095                        blob: BASE64.encode(bytes),
1096                    },
1097                );
1098            }
1099            // Too large for any frame. The rung above every frame is the
1100            // media path, which is where a document this size goes.
1101            Ok(bytes) => self.carry_snapshot_over_media(space, channel, doc, bytes),
1102            Err(err) => {
1103                warn!(space, doc, error = %err, "Could not export document for catch-up");
1104            }
1105        }
1106    }
1107
1108    /// Send a document the media path carries, because no frame can.
1109    ///
1110    /// The top rung of the catch-up ladder and the last one. A document that
1111    /// cannot go this way either is reported rather than retried: nothing
1112    /// above this exists to try, and the two replicas stay apart until
1113    /// somebody makes the document smaller.
1114    ///
1115    /// Only ever 1:1 in this version. The media path is a transfer to a
1116    /// confirmed session and two members of a group need not have one with
1117    /// each other, so a group space that reaches this rung reports instead.
1118    fn carry_snapshot_over_media(
1119        &mut self,
1120        space: &str,
1121        channel: &SyncChannel,
1122        doc: &str,
1123        bytes: Vec<u8>,
1124    ) {
1125        let size = bytes.len();
1126        let report = |protocol: &mut Self, reason: &str| {
1127            warn!(
1128                space,
1129                doc,
1130                bytes = size,
1131                reason,
1132                "Document cannot be replicated"
1133            );
1134            if let Ok(state) = crate::protocol::lock_shared_state(&protocol.shared_state) {
1135                state.emit_event(Event::DataDocUnsyncable {
1136                    space_id: space.to_string(),
1137                    doc_id: doc.to_string(),
1138                    bytes: size as u64,
1139                    reason: reason.to_string(),
1140                });
1141            }
1142        };
1143
1144        if !matches!(channel, SyncChannel::Peer) {
1145            report(self, "group_space_has_no_media_path");
1146            return;
1147        }
1148        if size > MAX_MEDIA_SNAPSHOT_BYTES {
1149            report(self, "over_record_ceiling");
1150            return;
1151        }
1152        if !self.data_media_active(space) {
1153            report(self, "peer_cannot_carry_snapshots");
1154            return;
1155        }
1156
1157        // Not a dead end and not reported as one: the document is already on
1158        // its way. This rung is re-entered on its own while a carriage runs,
1159        // because the peer stays stale for the whole of it and says so on
1160        // every exchange.
1161        let purpose = DataPurpose::Snapshot {
1162            doc: doc.to_string(),
1163        };
1164        if self.data_media_transfer_in_flight(space, &purpose) {
1165            debug!(
1166                space,
1167                doc, "This document is already crossing to the peer; not starting a second copy"
1168            );
1169            return;
1170        }
1171
1172        debug!(
1173            space,
1174            doc,
1175            bytes = size,
1176            "Carrying a snapshot over the media path"
1177        );
1178        if let Err(err) = self.send_media_inner(
1179            space.to_string(),
1180            bytes,
1181            doc.to_string(),
1182            ContentType::File,
1183            MediaSendOptions::default(),
1184            Some(purpose),
1185        ) {
1186            // Transient by nature: no confirmed session yet, the per-peer
1187            // transfer slots are full, or the protocol is not running.
1188            // Reported at debug and left for anti-entropy, which asks again
1189            // on the next exchange. Raising it as unsyncable would tell an
1190            // application a document is permanently stuck when it is merely
1191            // busy.
1192            debug!(space, doc, error = %err, "Snapshot carriage deferred");
1193        }
1194    }
1195
1196    /// Answer a peer that cannot apply what we sent it.
1197    ///
1198    /// Only a document already held is served. A request naming an unknown
1199    /// one would otherwise create it, which is a way to spend this device's
1200    /// storage that does not even require a blob.
1201    fn answer_snapshot_request(
1202        &mut self,
1203        space: &str,
1204        channel: &SyncChannel,
1205        doc: &str,
1206    ) -> Result<()> {
1207        if offline_protocol_data::validate_name(doc).is_err() {
1208            warn!(space, doc, "Snapshot request names an invalid document");
1209            return Ok(());
1210        }
1211        if !self.data_holds_doc(space, doc) {
1212            debug!(space, doc, "Snapshot request for a document we do not hold");
1213            return Ok(());
1214        }
1215        debug!(space, doc, "Serving a snapshot the peer asked for");
1216        self.send_snapshot(space, channel, doc);
1217        Ok(())
1218    }
1219
1220    /// Apply a blob from a peer, with the containment the engine needs.
1221    fn accept_remote_blob(
1222        &mut self,
1223        space: &str,
1224        channel: &SyncChannel,
1225        doc: &str,
1226        encoded: &str,
1227        kind: BlobKind,
1228    ) -> Result<()> {
1229        if offline_protocol_data::validate_name(doc).is_err() {
1230            warn!(space, doc, "Sync frame names an invalid document");
1231            return Ok(());
1232        }
1233        // A blob for a document this device does not hold creates one, so
1234        // this path is bounded by the same ceiling the offer loop is.
1235        if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1236            warn!(
1237                space,
1238                doc,
1239                cap = MAX_DOCS_PER_SPACE,
1240                "Space is at its document ceiling; refusing a new one"
1241            );
1242            return Ok(());
1243        }
1244        // Bound the decode by the frame budget: base64 grows by a third, so
1245        // checking the encoded length first refuses an oversized blob before
1246        // allocating for it.
1247        if encoded.len() > MAX_SYNC_BLOB_BYTES * 4 / 3 + 4 {
1248            warn!(space, doc, "Sync frame carries an oversized blob");
1249            return Ok(());
1250        }
1251        let blob = match BASE64.decode(encoded) {
1252            Ok(blob) => blob,
1253            Err(err) => {
1254                warn!(space, doc, error = %err, "Undecodable blob in a sync frame");
1255                return Ok(());
1256            }
1257        };
1258        if blob.len() > MAX_SYNC_BLOB_BYTES {
1259            warn!(
1260                space,
1261                doc,
1262                bytes = blob.len(),
1263                "Sync blob over the frame budget"
1264            );
1265            return Ok(());
1266        }
1267
1268        self.apply_contained_blob(space, channel, doc, blob, kind)
1269    }
1270
1271    /// Apply remote document bytes with the containment the engine needs.
1272    ///
1273    /// Split from [`Self::accept_remote_blob`] so bytes that arrived over
1274    /// the media path go through exactly this, rather than through a second
1275    /// copy of it. The road the bytes travelled says nothing about what they
1276    /// are: both roads carry an import that can still end the process, from
1277    /// a peer who is authenticated and may still be wrong.
1278    ///
1279    /// The frame budget is deliberately not checked here. It bounds what one
1280    /// frame may carry, which is a fact about frames; the media path has its
1281    /// own, larger bound because it has no such limit.
1282    fn apply_contained_blob(
1283        &mut self,
1284        space: &str,
1285        channel: &SyncChannel,
1286        doc: &str,
1287        blob: Vec<u8>,
1288        kind: BlobKind,
1289    ) -> Result<()> {
1290        if offline_protocol_data::validate_name(doc).is_err() {
1291            warn!(space, doc, "A remote blob names an invalid document");
1292            return Ok(());
1293        }
1294        if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
1295            warn!(
1296                space,
1297                doc,
1298                cap = MAX_DOCS_PER_SPACE,
1299                "Space is at its document ceiling; refusing a new one"
1300            );
1301            return Ok(());
1302        }
1303
1304        let digest = blob_digest(&blob);
1305        let mut record = self.load_sync_record(space);
1306        if record.quarantined.contains(&digest) {
1307            // This blob was in flight when a previous run died. It may be a
1308            // coincidence and it may be the reason; either way applying it
1309            // again to find out is a bad trade, because the cost of being
1310            // wrong is the process and the cost of being right is one
1311            // document change that the sender still holds.
1312            warn!(space, doc, "Refusing a quarantined blob");
1313            return Ok(());
1314        }
1315
1316        // Local edits waiting in memory are committed and pushed *before*
1317        // the import, so they leave on their own delta.
1318        //
1319        // A commit exports everything since the last one, regardless of who
1320        // authored it. So a local edit still pending when a remote blob
1321        // applies is folded into the same delta as the imported change, and
1322        // that delta is then suppressed as an echo toward the one peer it
1323        // was owed to. The edit is durable and announced to nobody: the next
1324        // commit exports only what came after it, and on a link that never
1325        // drops there is no reconnect to run an exchange that would notice.
1326        // Draining first costs one extra delta record and keeps the echo
1327        // suppression judging a delta that really is nothing but the echo.
1328        //
1329        // Reported and stepped over rather than propagated. This is our own
1330        // housekeeping, and a document held over its cap fails here on every
1331        // flush while still needing to accept the remote deletions that
1332        // bring it back under: refusing the import for it would close the
1333        // only door out.
1334        //
1335        // Ahead of the marker below rather than inside its window, so the
1336        // window stays the length of one import. A crash during this flush
1337        // would otherwise quarantine a blob the engine never saw.
1338        //
1339        // The two errors say opposite things about what is still pending, so
1340        // they are told apart. `DocTooLarge` is raised after the delta record
1341        // was written, pushed and announced, and only the size verdict that
1342        // follows it failed: nothing is left in the pending set and there is
1343        // nothing to compensate for. Any other error may have left the commit
1344        // un-exported, because the delta-write failure rewinds it back into
1345        // the pending set. The import's own flush would then fold that edit
1346        // into the imported change and suppress the pair toward the one peer
1347        // it was owed to, which is exactly the loss this pre-flush exists to
1348        // prevent.
1349        let mut preflush_left_edits_pending = false;
1350        match self.data_flush(space, doc) {
1351            Ok(()) => {}
1352            Err(Error::DocTooLarge { .. }) => {
1353                debug!(
1354                    space,
1355                    doc,
1356                    "Local edits were flushed and pushed before applying a remote change; the \
1357                     document is over its cap and refuses further growth"
1358                );
1359            }
1360            Err(err) => {
1361                warn!(
1362                    space,
1363                    doc,
1364                    error = %err,
1365                    "Could not flush local edits before applying a remote change; they may \
1366                     still be pending"
1367                );
1368                preflush_left_edits_pending = true;
1369            }
1370        }
1371
1372        // Written *before* the engine is called. That ordering is the whole
1373        // mechanism: a blob that ends the process leaves its digest behind,
1374        // so the sender's next retry is refused instead of ending the next
1375        // process too.
1376        record.in_flight = Some(digest.clone());
1377        self.persist_sync_record(space, &record);
1378
1379        let outcome = self.data_apply_remote(space, doc, &blob);
1380
1381        record.in_flight = None;
1382        self.persist_sync_record(space, &record);
1383
1384        // Read before the match below consumes the outcome.
1385        let applied = matches!(outcome, Ok(RemoteImport::Applied));
1386
1387        match outcome {
1388            Ok(RemoteImport::Applied) => {
1389                debug!(space, doc, "Applied a remote change");
1390            }
1391            Ok(RemoteImport::AlreadyHave) => {}
1392            Ok(RemoteImport::Parked) => match kind {
1393                // The change is held behind a predecessor that has not
1394                // arrived. Asking for the gap now is what makes the parked
1395                // change durable: it lives only in the engine's memory, so a
1396                // restart before the predecessor lands would lose it, and
1397                // the version exchange is what brings both back. Scoped to
1398                // this document, because this document is what is missing
1399                // something.
1400                BlobKind::Delta => {
1401                    debug!(space, doc, "Remote change parked; asking for the gap");
1402                    self.request_doc_catch_up(space, channel, doc);
1403                }
1404                // A snapshot is the top of the ladder. Asking again returns
1405                // the same bytes, so this is reported and left, rather than
1406                // retried until one side gives up or the battery does.
1407                BlobKind::Snapshot => {
1408                    warn!(
1409                        space,
1410                        doc,
1411                        "A snapshot from the peer did not apply; these replicas cannot be \
1412                         reconciled inline"
1413                    );
1414                }
1415            },
1416            Ok(RemoteImport::RefusedTrimmedHistory) => match kind {
1417                // Nothing the peer can compute from our version closes this
1418                // gap: they would recompute the same refused delta, and
1419                // answering with a version offer is how that becomes an
1420                // exchange neither side can end. Name what would work.
1421                BlobKind::Delta => {
1422                    debug!(space, doc, "Remote change needs a snapshot; asking for one");
1423                    self.send_sync_frame(
1424                        space,
1425                        channel,
1426                        &SyncBody::NeedSnapshot {
1427                            doc: doc.to_string(),
1428                        },
1429                    );
1430                }
1431                // The end of the line, and the one outcome this layer
1432                // cannot recover from. The peer forked below a point this
1433                // replica compacted away, so the ancestors their changes
1434                // need were deleted here rather than left out of the
1435                // message: no frame carries them back, including the whole
1436                // document, which is what just arrived. Reported plainly
1437                // rather than retried, because a retry returns these same
1438                // bytes and refusing them is the only thing keeping the
1439                // process alive.
1440                BlobKind::Snapshot => {
1441                    warn!(
1442                        space,
1443                        doc,
1444                        "A snapshot from the peer cannot merge into this replica; the two \
1445                         have diverged past what replication can reconcile"
1446                    );
1447                }
1448            },
1449            Err(err) => match kind {
1450                BlobKind::Delta => {
1451                    warn!(space, doc, error = %err, "Remote change refused");
1452                }
1453                // The top of the ladder, so there is nothing above it to
1454                // ask for. Unreconcilable divergence is refused before it
1455                // gets here rather than failing here, which leaves this for
1456                // bytes that are simply bad.
1457                BlobKind::Snapshot => {
1458                    warn!(space, doc, error = %err, "A snapshot from the peer was unreadable");
1459                }
1460            },
1461        }
1462
1463        // A pre-flush that failed followed by an import that applied is the
1464        // one combination where a local edit can have been folded into the
1465        // imported change and then suppressed toward the peer it was owed
1466        // to. Nothing on the wire carries it and no trigger is left to
1467        // notice, so the gap is announced and the peer asks for what it is
1468        // missing. The alternative, sending the fold on with the suppression
1469        // lifted, would hand the peer its own change back.
1470        //
1471        // Gated on `Applied` for the reason that also makes it terminate.
1472        // The fold needs the import's own flush to have succeeded, and a
1473        // storage failure that is still failing fails that one too, which
1474        // makes the import answer `Err`. So every nudge costs a fresh
1475        // transient failure that recovered inside one frame, and the offers
1476        // cannot sustain each other. `Applied` and not "anything but `Err`"
1477        // for the same reason: the outcomes that did not apply left the edit
1478        // pending, where the next pre-flush still owes it to the peer.
1479        //
1480        // This is why a document over its cap must answer `Applied` rather
1481        // than `DocTooLarge`, and does: its flush wrote the fold and then
1482        // refused further growth, so the edit is stranded exactly as here,
1483        // on the documents whose only pending edit is the deletion that
1484        // brings them back under.
1485        //
1486        // One residual is left uncompensated on purpose. A `persist_space`
1487        // failure after a flush that succeeded also strands a fold, and is
1488        // indistinguishable here from a flush that failed. Offering on every
1489        // `Err` would cover it and break the argument above: a store that
1490        // keeps failing would then emit an offer per frame, and those do
1491        // sustain each other. The narrower gap is the better trade.
1492        //
1493        // `origin` is `None`, not the space: what may be stranded here is
1494        // this device's own edit, so naming the space would suppress the
1495        // announcement toward the only peer that could act on it.
1496        if preflush_left_edits_pending && applied {
1497            self.nudge_data_sync(space, None, "preflush_failure");
1498        }
1499
1500        Ok(())
1501    }
1502
1503    // ---- attachments -------------------------------------------------
1504
1505    /// Key of a pending fetch: the space asked in, then the blob wanted.
1506    ///
1507    /// Composed rather than keyed by hash alone because the same bytes may
1508    /// be referenced from two spaces, and an answer from one peer must not
1509    /// close a question put to another.
1510    fn attachment_fetch_key(space: &str, hash: &str) -> String {
1511        format!("{space}{GROUP_OFFER_KEY_SEP}{hash}")
1512    }
1513
1514    /// The SHA-256 of `bytes`, in the spelling an attachment reference uses.
1515    ///
1516    /// Exposed because an application writing a reference needs the address
1517    /// of its own bytes, and computing it anywhere else risks a second
1518    /// spelling of the same hash.
1519    pub fn data_attachment_hash(bytes: &[u8]) -> String {
1520        hex_encode(&Sha256::digest(bytes))
1521    }
1522
1523    /// Ask the peer a 1:1 space is named after for the bytes behind a
1524    /// reference.
1525    ///
1526    /// Pull rather than push, and the reason is bandwidth somebody else
1527    /// pays for: a space may hold references to more bytes than a phone
1528    /// wants over Bluetooth, so the decision to spend that belongs to the
1529    /// application, taken per blob, at the moment somebody opens one.
1530    ///
1531    /// The answer arrives later as [`Event::DataAttachmentReceived`] or
1532    /// [`Event::DataAttachmentUnavailable`], never inline.
1533    ///
1534    /// Group spaces are refused in this version. A blob travels the media
1535    /// path, which is a 1:1 transfer to a confirmed session, and two members
1536    /// of a group need not have one with each other.
1537    pub fn data_fetch_attachment(&mut self, space: &str, hash: &str) -> Result<()> {
1538        if !self.config.data.enabled {
1539            return Err(Error::DataDisabled);
1540        }
1541        Self::validate_attachment_hash(hash)?;
1542        if self.group_space_roster(space).is_some() {
1543            return Err(Error::InvalidArgument(format!(
1544                "space {space} is a group; attachment carriage is 1:1 in this version"
1545            )));
1546        }
1547        if !self.data_media_active(space) {
1548            return Err(Error::InvalidArgument(format!(
1549                "peer {space} did not advertise attachment carriage"
1550            )));
1551        }
1552
1553        let key = Self::attachment_fetch_key(space, hash);
1554        self.expire_attachment_fetches();
1555        // The requester's own bound, which the spec makes a MUST and which
1556        // the holder's window cannot substitute for: an application retrying
1557        // a spinner on a timer would otherwise seal and send one frame per
1558        // retry, and the holder answering none of them is the case that
1559        // provokes the retries.
1560        if let Some(asked_at) = self.pending_attachment_fetches.get(&key) {
1561            if Instant::now().duration_since(*asked_at) < DATA_SYNC_OFFER_INTERVAL {
1562                debug!(
1563                    space,
1564                    "Blob fetch repeated inside its window; already asked"
1565                );
1566                return Ok(());
1567            }
1568        }
1569        if self.pending_attachment_fetches.len() >= MAX_PENDING_ATTACHMENT_FETCHES
1570            && !self.pending_attachment_fetches.contains_key(&key)
1571        {
1572            // Oldest by last sign of life rather than by age, which is what
1573            // keeps a fetch whose bytes are actively arriving out of this:
1574            // every chunk of an answer refreshes its entry, so a live
1575            // carriage sits among the newest and is never the one displaced.
1576            if let Some(oldest) = self
1577                .pending_attachment_fetches
1578                .iter()
1579                .min_by_key(|(_, seen_at)| **seen_at)
1580                .map(|(key, _)| key.clone())
1581            {
1582                self.pending_attachment_fetches.remove(&oldest);
1583                // Reported, exactly as an expiry is. Eviction reaches the
1584                // same end state by a different road: the fetch is over and
1585                // no bytes will ever be admitted for it, so an application
1586                // told nothing is left showing the spinner that the whole
1587                // refusal mechanism exists to end.
1588                Self::report_fetch_ended(&self.shared_state, &oldest, "evicted");
1589            }
1590        }
1591        self.pending_attachment_fetches.insert(key, Instant::now());
1592
1593        self.send_sync_frame(
1594            space,
1595            &SyncChannel::Peer,
1596            &SyncBody::NeedBlob {
1597                hash: hash.to_string(),
1598            },
1599        );
1600        Ok(())
1601    }
1602
1603    /// Answer a peer's request with the bytes.
1604    ///
1605    /// The bytes come from the application because this SDK never held them:
1606    /// a blob does not enter a document and does not enter protocol state,
1607    /// so the only copy is wherever the app put it.
1608    ///
1609    /// Refuses bytes that do not hash to `hash`. That check is here rather
1610    /// than only on the receiving side so a mistake is reported to the app
1611    /// that made it, while it still has the file in hand, instead of
1612    /// travelling the whole media path to be refused by somebody else.
1613    pub fn data_provide_attachment(
1614        &mut self,
1615        space: &str,
1616        peer: &str,
1617        hash: &str,
1618        bytes: Vec<u8>,
1619    ) -> Result<()> {
1620        if !self.config.data.enabled {
1621            return Err(Error::DataDisabled);
1622        }
1623        Self::validate_attachment_hash(hash)?;
1624        if bytes.is_empty() {
1625            return Err(Error::InvalidArgument(
1626                "an attachment must have bytes".to_string(),
1627            ));
1628        }
1629        if space != peer {
1630            return Err(Error::InvalidArgument(format!(
1631                "space {space} is not the 1:1 space with {peer}"
1632            )));
1633        }
1634        let actual = Self::data_attachment_hash(&bytes);
1635        if actual != hash {
1636            return Err(Error::InvalidArgument(format!(
1637                "these bytes hash to {actual}, not to {hash}"
1638            )));
1639        }
1640
1641        // Answering twice is not an error to report back: the app did as it
1642        // was asked, and the bytes are on their way. Idempotent rather than
1643        // refused, because a request repeated after its window reopens is
1644        // ordinary and an app has no way to see that its first answer is
1645        // still in flight.
1646        let purpose = DataPurpose::Attachment {
1647            hash: hash.to_string(),
1648        };
1649        if self.data_media_transfer_in_flight(peer, &purpose) {
1650            debug!(
1651                peer,
1652                "These bytes are already crossing to this peer; not starting a second copy"
1653            );
1654            return Ok(());
1655        }
1656
1657        self.send_media_inner(
1658            peer.to_string(),
1659            bytes,
1660            // The media layer wants a file name and an attachment has none
1661            // that is load-bearing: the display name lives on the reference
1662            // in the document, where every replica can already read it.
1663            hash.to_string(),
1664            ContentType::File,
1665            MediaSendOptions::default(),
1666            Some(purpose),
1667        )?;
1668        Ok(())
1669    }
1670
1671    /// Tell a peer their fetch will not be answered.
1672    ///
1673    /// A first-class answer rather than silence. The asking side cannot
1674    /// otherwise tell a peer that no longer holds the bytes from one that is
1675    /// merely slow, and shows a person a spinner that never resolves.
1676    pub fn data_decline_attachment(&mut self, space: &str, peer: &str, hash: &str) -> Result<()> {
1677        if !self.config.data.enabled {
1678            return Err(Error::DataDisabled);
1679        }
1680        Self::validate_attachment_hash(hash)?;
1681        if space != peer {
1682            return Err(Error::InvalidArgument(format!(
1683                "space {space} is not the 1:1 space with {peer}"
1684            )));
1685        }
1686        // Gated like every other frame in this file, and it is the one that
1687        // was not. A refusal toward a peer that never advertised the
1688        // capability is unreadable to them, so the caller believes they
1689        // answered and the asking side keeps waiting; toward a blocked peer
1690        // it is a liveness signal blocking exists to withhold.
1691        if !self.data_media_active(peer) {
1692            return Err(Error::InvalidArgument(format!(
1693                "peer {peer} did not advertise attachment carriage"
1694            )));
1695        }
1696        self.send_sync_frame(
1697            space,
1698            &SyncChannel::Peer,
1699            &SyncBody::BlobGone {
1700                hash: hash.to_string(),
1701            },
1702        );
1703        Ok(())
1704    }
1705
1706    /// Whether a peer's request for one blob should reach the application.
1707    ///
1708    /// Two bounds, because a peer controls both how often it asks and what it
1709    /// asks for. The per-hash window stops a repeat; the per-peer budget
1710    /// stops a flood of distinct hashes, which the window alone cannot see as
1711    /// a flood at all because every hash in it is new.
1712    ///
1713    /// Kept out of the version-offer window map deliberately. Every other key
1714    /// in that map is a peer or a peer-and-group, both bounded by a roster.
1715    /// This one would be bounded by nothing, and that map has no cap and no
1716    /// sweep.
1717    fn blob_request_due(&mut self, peer: &str, hash: &str) -> bool {
1718        let now = Instant::now();
1719        self.blob_request_windows
1720            .retain(|_, seen| now.duration_since(*seen) < DATA_SYNC_OFFER_INTERVAL);
1721
1722        let key = format!("{peer}{GROUP_OFFER_KEY_SEP}{hash}");
1723        if self.blob_request_windows.contains_key(&key) {
1724            debug!(peer, "Blob request repeated inside its window; ignoring");
1725            return false;
1726        }
1727
1728        let live_for_peer = self
1729            .blob_request_windows
1730            .keys()
1731            .filter(|key| {
1732                key.split_once(GROUP_OFFER_KEY_SEP)
1733                    .is_some_and(|(asker, _)| asker == peer)
1734            })
1735            .count();
1736        if live_for_peer >= MAX_BLOB_REQUESTS_PER_WINDOW {
1737            warn!(
1738                peer,
1739                budget = MAX_BLOB_REQUESTS_PER_WINDOW,
1740                "Peer is over its blob-request budget; ignoring"
1741            );
1742            return false;
1743        }
1744
1745        // The global bound, which holds when a peer's budget resets and when
1746        // many peers each stay under theirs.
1747        if self.blob_request_windows.len() >= MAX_BLOB_REQUEST_WINDOWS {
1748            if let Some(oldest) = self
1749                .blob_request_windows
1750                .iter()
1751                .min_by_key(|(_, seen)| **seen)
1752                .map(|(key, _)| key.clone())
1753            {
1754                self.blob_request_windows.remove(&oldest);
1755            }
1756        }
1757        self.blob_request_windows.insert(key, now);
1758        true
1759    }
1760
1761    fn validate_attachment_hash(hash: &str) -> Result<()> {
1762        offline_protocol_data::validate_attachment_hash(hash)
1763            .map_err(|err| Error::InvalidArgument(err.to_string()))
1764    }
1765
1766    /// Whether this device is waiting for the blob behind `hash` from `peer`.
1767    ///
1768    /// Read before a transfer is admitted rather than after it completes.
1769    /// Refusing at the end still refuses, but only once the bytes have been
1770    /// buffered, reassembled and checksummed, which is the storage and the
1771    /// battery the check exists to protect.
1772    pub(crate) fn awaiting_attachment(&self, peer: &str, hash: &str) -> bool {
1773        self.pending_attachment_fetches
1774            .contains_key(&Self::attachment_fetch_key(peer, hash))
1775    }
1776
1777    /// Forget everything remembered about attachments with one peer.
1778    ///
1779    /// Composed through [`Self::attachment_fetch_key`] rather than by
1780    /// re-spelling the separator, which is what the separator constant exists
1781    /// for: a key built one way and matched another is a leak that no test
1782    /// notices until the two spellings diverge.
1783    pub(crate) fn forget_attachment_state(&mut self, peer: &str) {
1784        let prefix = Self::attachment_fetch_key(peer, "");
1785        // Reported, not merely dropped. This is a road that ends a fetch
1786        // without bytes like any other, and it is reached while an
1787        // application is waiting: a peer re-keying without the capability,
1788        // an unblock, a peer forgotten to keep a set bounded. Removing the
1789        // record silently also forecloses the expiry that would have
1790        // reported it later, so the spinner this event exists to end never
1791        // ends at all.
1792        let ended: Vec<String> = self
1793            .pending_attachment_fetches
1794            .keys()
1795            .filter(|key| key.starts_with(&prefix))
1796            .cloned()
1797            .collect();
1798        for key in ended {
1799            self.pending_attachment_fetches.remove(&key);
1800            Self::report_fetch_ended(&self.shared_state, &key, "peer_gone");
1801        }
1802        self.blob_request_windows
1803            .retain(|key, _| !key.starts_with(&prefix));
1804    }
1805
1806    /// [`Self::forget_attachment_state`] for every peer at once, for the
1807    /// road that forgets every peer at once.
1808    ///
1809    /// Not a loop over the roster, because the caller has already emptied
1810    /// it: the capability sets are cleared wholesale when the bound on
1811    /// remembered peers is reached, so by then there is no peer left to name
1812    /// and a fetch keyed by one would be swept by nothing.
1813    pub(crate) fn end_every_pending_fetch(&mut self) {
1814        for key in std::mem::take(&mut self.pending_attachment_fetches).into_keys() {
1815            Self::report_fetch_ended(&self.shared_state, &key, "peer_gone");
1816        }
1817    }
1818
1819    /// Report a fetch this device has given up on, keyed the way
1820    /// [`Self::attachment_fetch_key`] composes them.
1821    ///
1822    /// Shared by every road that ends a fetch without bytes, because they all
1823    /// owe the application the same thing and a second spelling of this event
1824    /// is a road that quietly owes it nothing. `reason` is a fixed token this
1825    /// crate chooses, never a peer's words: see the producer rule in
1826    /// `telemetry::scrub_event`.
1827    fn report_fetch_ended(
1828        shared_state: &std::sync::Arc<std::sync::Mutex<crate::protocol::SharedState>>,
1829        key: &str,
1830        reason: &str,
1831    ) {
1832        let Some((space, hash)) = key.split_once(GROUP_OFFER_KEY_SEP) else {
1833            return;
1834        };
1835        debug!(space, reason, "A blob fetch ended with no bytes");
1836        if let Ok(state) = crate::protocol::lock_shared_state(shared_state) {
1837            state.emit_event(Event::DataAttachmentUnavailable {
1838                // A 1:1 space IS the peer, which is why one key composes
1839                // both: a fetch is only ever made in a space named after the
1840                // peer that can answer it.
1841                space_id: space.to_string(),
1842                peer_id: space.to_string(),
1843                hash: hash.to_string(),
1844                reason: reason.to_string(),
1845            });
1846        }
1847    }
1848
1849    /// Note that the answer to a fetch is on its way.
1850    ///
1851    /// A transfer that is visibly progressing is the answer this device is
1852    /// waiting for, so the question must not expire while it arrives. Without
1853    /// this the record times out mid-carriage and
1854    /// [`Self::accept_data_media_payload`] then drops a blob that fully
1855    /// arrived as one nobody asked for, which no retry can fix: the retry
1856    /// carries the same bytes over the same radio and expires at the same
1857    /// point.
1858    ///
1859    /// Only an attachment has a record to refresh. A snapshot is unsolicited
1860    /// by design and keeps none.
1861    pub(crate) fn refresh_attachment_fetch(&mut self, peer: &str, purpose: &DataPurpose) {
1862        let DataPurpose::Attachment { hash } = purpose else {
1863            return;
1864        };
1865        let key = Self::attachment_fetch_key(peer, hash);
1866        if let Some(seen_at) = self.pending_attachment_fetches.get_mut(&key) {
1867            *seen_at = Instant::now();
1868        }
1869    }
1870
1871    /// Whether a transfer for exactly this purpose is already on its way to
1872    /// this peer.
1873    ///
1874    /// Both roads onto the media path are re-entered by things that recur on
1875    /// their own: a peer stays stale for the whole multi-minute carriage of a
1876    /// document, so every anti-entropy exchange in that window reaches the
1877    /// top rung again, and an application answering a repeated request has no
1878    /// way to know its first answer is still in flight.
1879    ///
1880    /// Without this check those repeats become second copies of one transfer.
1881    /// A peer may hold only [`MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER`] at
1882    /// once, so the duplicates halve the throughput of the transfer that
1883    /// matters and then fill the slots, and the next thing to fail is the
1884    /// application's own media send, with a limit error whose cause it cannot
1885    /// see: the transfers holding those slots are invisible to it by design.
1886    ///
1887    /// [`MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER`]: crate::constants::MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER
1888    fn data_media_transfer_in_flight(&self, recipient: &str, purpose: &DataPurpose) -> bool {
1889        self.outbound_media_transfers.values().any(|transfer| {
1890            transfer.recipient == recipient && transfer.data_purpose.as_ref() == Some(purpose)
1891        })
1892    }
1893
1894    /// Give up on fetches nobody answered, and say so.
1895    ///
1896    /// The reporting half is the point. A holder is only asked to answer
1897    /// `need_blob`, never required to, so a fetch can go unanswered forever
1898    /// with nothing wrong anywhere. Expiring the entry silently would leave
1899    /// the asking application exactly where the whole refusal mechanism
1900    /// exists to stop it being: showing somebody a spinner that never
1901    /// resolves.
1902    pub(crate) fn expire_attachment_fetches(&mut self) {
1903        let now = Instant::now();
1904        let expired: Vec<String> = self
1905            .pending_attachment_fetches
1906            .iter()
1907            .filter(|(_, asked_at)| now.duration_since(**asked_at) >= ATTACHMENT_FETCH_TIMEOUT)
1908            .map(|(key, _)| key.clone())
1909            .collect();
1910        for key in expired {
1911            self.pending_attachment_fetches.remove(&key);
1912            Self::report_fetch_ended(&self.shared_state, &key, "timeout");
1913        }
1914    }
1915
1916    /// A peer wants the bytes behind a reference.
1917    ///
1918    /// Handed straight to the application, because the application is the
1919    /// only party that has them. Rate limited per blob on the window that
1920    /// suppresses version offers: a peer retrying a fetch must not turn into
1921    /// a stream of events, and the answer to the second ask within the
1922    /// window is the same as the answer to the first.
1923    fn answer_blob_request(
1924        &mut self,
1925        space: &str,
1926        channel: &SyncChannel,
1927        hash: &str,
1928    ) -> Result<()> {
1929        if !matches!(channel, SyncChannel::Peer) {
1930            warn!(
1931                space,
1932                "A blob request arrived inside a group; attachment carriage is 1:1"
1933            );
1934            return Ok(());
1935        }
1936        if Self::validate_attachment_hash(hash).is_err() {
1937            warn!(space, "Blob request names something that is not a hash");
1938            return Ok(());
1939        }
1940        // A request this device could not answer is not put to the
1941        // application. Both answers to `DataAttachmentRequested` refuse
1942        // toward a peer that never advertised carriage: `provide` cannot
1943        // reach the media path and `decline` cannot reach the wire. Emitting
1944        // it anyway hands an app a question with no legal reply, which reads
1945        // to whoever handles it as an SDK that rejects its own events.
1946        //
1947        // Ahead of the rate limiter rather than behind it, so a peer that
1948        // cannot be answered spends no window slot: the budget exists to
1949        // bound work this device would otherwise do, and there is none here.
1950        if !self.data_media_active(space) {
1951            warn!(
1952                space,
1953                "A blob request came from a peer that never advertised carriage; ignoring"
1954            );
1955            return Ok(());
1956        }
1957        if !self.blob_request_due(space, hash) {
1958            return Ok(());
1959        }
1960        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
1961            state.emit_event(Event::DataAttachmentRequested {
1962                space_id: space.to_string(),
1963                peer_id: space.to_string(),
1964                hash: hash.to_string(),
1965            });
1966        }
1967        Ok(())
1968    }
1969
1970    /// A peer says the bytes are gone. End the fetch.
1971    ///
1972    /// Checked the same way [`Self::answer_blob_request`] is, and for the
1973    /// same reason rather than a hypothetical one: a fetch is 1:1 state, and
1974    /// a frame arriving over a group would compose a key from a group id.
1975    /// Unreachable today because a group id cannot collide with an address,
1976    /// which is exactly the kind of accident a later refactor removes.
1977    fn report_blob_gone(&mut self, channel: &SyncChannel, space: &str, hash: &str) -> Result<()> {
1978        if !matches!(channel, SyncChannel::Peer) {
1979            warn!(
1980                space,
1981                "A blob refusal arrived inside a group; attachment carriage is 1:1"
1982            );
1983            return Ok(());
1984        }
1985        if Self::validate_attachment_hash(hash).is_err() {
1986            warn!(space, "Blob refusal names something that is not a hash");
1987            return Ok(());
1988        }
1989        let key = Self::attachment_fetch_key(space, hash);
1990        // Only a fetch this device actually made is reported. Otherwise a
1991        // peer could emit refusals for blobs nobody asked about.
1992        if self.pending_attachment_fetches.remove(&key).is_none() {
1993            debug!(space, "A blob refusal answers no question we asked");
1994            return Ok(());
1995        }
1996        if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
1997            state.emit_event(Event::DataAttachmentUnavailable {
1998                space_id: space.to_string(),
1999                peer_id: space.to_string(),
2000                hash: hash.to_string(),
2001                reason: "declined".to_string(),
2002            });
2003        }
2004        Ok(())
2005    }
2006
2007    // ---- what arrives over the media path ------------------------------
2008
2009    /// A data-purposed media transfer completed. Route it by its purpose.
2010    ///
2011    /// The space is the authenticated wire sender, never anything the
2012    /// transfer said about itself, which is the same rule every sync frame
2013    /// follows and for the same reason: a space a peer can name is a space a
2014    /// peer can reach.
2015    ///
2016    /// Gated on the kill switch, which is belt to the storage seam's braces
2017    /// rather than the thing that stops an import: `require_data_storage`
2018    /// already fails closed with `DataDisabled`, so a device with the layer
2019    /// off writes nothing regardless. This makes the refusal local and free,
2020    /// and keeps the rule uniform across both roads into this layer. The
2021    /// admission check in `receive` is the one that earns its keep, because
2022    /// it is the one that refuses before a whole blob is buffered.
2023    pub(crate) fn accept_data_media_payload(
2024        &mut self,
2025        sender: &str,
2026        purpose: &DataPurpose,
2027        bytes: Vec<u8>,
2028    ) {
2029        if !self.config.data.enabled {
2030            debug!(
2031                peer = %sender,
2032                "Document-layer transfer dropped: the data layer is off"
2033            );
2034            return;
2035        }
2036        match purpose {
2037            DataPurpose::Attachment { hash } => {
2038                let key = Self::attachment_fetch_key(sender, hash);
2039                if self.pending_attachment_fetches.remove(&key).is_none() {
2040                    warn!(
2041                        peer = %sender,
2042                        "Attachment bytes arrived for a fetch this device never made; dropping"
2043                    );
2044                    return;
2045                }
2046                // The address is checked against the bytes, not against what
2047                // the sender says about them. This is what makes fetching
2048                // from an authenticated peer safe without trusting that
2049                // peer: the worst a wrong answer achieves is no answer.
2050                let actual = Self::data_attachment_hash(&bytes);
2051                if &actual != hash {
2052                    warn!(
2053                        peer = %sender,
2054                        "Attachment bytes do not hash to what was asked for; dropping"
2055                    );
2056                    if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
2057                        state.emit_event(Event::DataAttachmentUnavailable {
2058                            space_id: sender.to_string(),
2059                            peer_id: sender.to_string(),
2060                            hash: hash.clone(),
2061                            reason: "hash_mismatch".to_string(),
2062                        });
2063                    }
2064                    return;
2065                }
2066                if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
2067                    state.emit_event(Event::DataAttachmentReceived {
2068                        space_id: sender.to_string(),
2069                        peer_id: sender.to_string(),
2070                        hash: hash.clone(),
2071                        data: BASE64.encode(&bytes),
2072                    });
2073                }
2074            }
2075            DataPurpose::Snapshot { doc } => {
2076                if bytes.len() > MAX_MEDIA_SNAPSHOT_BYTES {
2077                    warn!(
2078                        peer = %sender,
2079                        bytes = bytes.len(),
2080                        "A carried snapshot is larger than a record can hold; refusing"
2081                    );
2082                    return;
2083                }
2084                // Straight into the containment every remote blob goes
2085                // through. Arriving by a longer road changes nothing about
2086                // what these bytes are: an import that can still end the
2087                // process, from a peer who is exactly who they say they are.
2088                let doc = doc.clone();
2089                if let Err(err) = self.apply_contained_blob(
2090                    sender,
2091                    &SyncChannel::Peer,
2092                    &doc,
2093                    bytes,
2094                    BlobKind::Snapshot,
2095                ) {
2096                    warn!(peer = %sender, doc, error = %err, "A carried snapshot was refused");
2097                }
2098            }
2099        }
2100    }
2101
2102    /// A data-purposed media transfer failed. Say so where it can be acted on.
2103    pub(crate) fn report_data_media_failure(
2104        &mut self,
2105        sender: &str,
2106        purpose: &DataPurpose,
2107        reason: &str,
2108    ) {
2109        match purpose {
2110            DataPurpose::Attachment { hash } => {
2111                let key = Self::attachment_fetch_key(sender, hash);
2112                // Only a fetch still outstanding is reported, the same rule
2113                // the completion and refusal roads already follow. A fetch
2114                // that ended while its transfer was still moving (declined
2115                // mid-carriage, evicted, expired) has had its one answer,
2116                // and a second one for the same hash reads to an
2117                // application as a second failure of a thing it is no
2118                // longer waiting for.
2119                if self.pending_attachment_fetches.remove(&key).is_none() {
2120                    debug!(
2121                        peer = %sender,
2122                        reason,
2123                        "A transfer failed for a fetch that has already ended"
2124                    );
2125                    return;
2126                }
2127                if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
2128                    state.emit_event(Event::DataAttachmentUnavailable {
2129                        space_id: sender.to_string(),
2130                        peer_id: sender.to_string(),
2131                        hash: hash.clone(),
2132                        reason: reason.to_string(),
2133                    });
2134                }
2135            }
2136            // Nothing to report to an application: a snapshot that did not
2137            // arrive is a gap anti-entropy will find again, and the next
2138            // exchange asks for it afresh.
2139            DataPurpose::Snapshot { doc } => {
2140                debug!(peer = %sender, doc, reason, "A carried snapshot did not arrive");
2141            }
2142        }
2143    }
2144
2145    // ---- the crash record -------------------------------------------
2146
2147    /// Read a space's replication bookkeeping, promoting anything left in
2148    /// flight by a previous run into the quarantine.
2149    fn load_sync_record(&mut self, space: &str) -> SyncRecord {
2150        let Some(storage) = self.data_storage_for_sync() else {
2151            return SyncRecord::default();
2152        };
2153        let mut record =
2154            match self.read_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space) {
2155                Ok(Some(bytes)) => serde_json::from_slice::<SyncRecord>(&bytes).unwrap_or_default(),
2156                _ => SyncRecord::default(),
2157            };
2158        if let Some(digest) = record.in_flight.take() {
2159            warn!(
2160                space,
2161                "A previous run did not survive applying a remote change; quarantining it"
2162            );
2163            if !record.quarantined.contains(&digest) {
2164                record.quarantined.push(digest);
2165                while record.quarantined.len() > MAX_QUARANTINED_BLOBS {
2166                    record.quarantined.remove(0);
2167                }
2168            }
2169            self.persist_sync_record(space, &record);
2170        }
2171        record
2172    }
2173
2174    fn persist_sync_record(&mut self, space: &str, record: &SyncRecord) {
2175        let Some(storage) = self.data_storage_for_sync() else {
2176            return;
2177        };
2178        let bytes = match serde_json::to_vec(record) {
2179            Ok(bytes) => bytes,
2180            Err(err) => {
2181                warn!(space, error = %err, "Could not encode replication bookkeeping");
2182                return;
2183            }
2184        };
2185        if let Err(err) =
2186            self.write_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space, &bytes)
2187        {
2188            // Not fatal, and deliberately not a refusal to apply: failing to
2189            // write the marker costs the crash protection for this one blob,
2190            // while refusing would stop replication on a storage hiccup.
2191            warn!(space, error = %err, "Could not persist replication bookkeeping");
2192        }
2193    }
2194}
2195
2196#[cfg(test)]
2197mod tests {
2198    use super::*;
2199
2200    #[test]
2201    fn a_frame_carries_its_version_where_a_future_build_can_find_it() {
2202        // The version has to be readable off a frame whose body this build
2203        // cannot parse, or every future format looks like corruption to
2204        // every older install.
2205        let body = SyncBody::Delta {
2206            doc: "notes".to_string(),
2207            blob: "AAAA".to_string(),
2208        };
2209        let json = serde_json::to_string(&body).unwrap();
2210        let framed = format!("{{\"v\":{},{}", DATA_SYNC_V1, &json[1..]);
2211
2212        let value: serde_json::Value = serde_json::from_str(&framed).unwrap();
2213        assert_eq!(value.get("v").and_then(|v| v.as_u64()), Some(1));
2214        assert_eq!(value.get("k").and_then(|v| v.as_str()), Some("delta"));
2215
2216        let round_trip: SyncBody = serde_json::from_value(value).unwrap();
2217        assert!(matches!(round_trip, SyncBody::Delta { doc, .. } if doc == "notes"));
2218    }
2219
2220    #[test]
2221    fn a_version_frame_round_trips() {
2222        let body = SyncBody::Versions {
2223            reply: true,
2224            partial: true,
2225            docs: BTreeMap::from([("notes".to_string(), "dgA=".to_string())]),
2226        };
2227        let json = serde_json::to_string(&body).unwrap();
2228        let parsed: SyncBody = serde_json::from_str(&json).unwrap();
2229        match parsed {
2230            SyncBody::Versions {
2231                reply,
2232                partial,
2233                docs,
2234            } => {
2235                assert!(reply);
2236                assert!(partial);
2237                assert_eq!(docs.get("notes").map(String::as_str), Some("dgA="));
2238            }
2239            other => panic!("expected a version frame, got {other:?}"),
2240        }
2241    }
2242
2243    #[test]
2244    fn an_offer_that_says_nothing_about_completeness_is_read_as_complete() {
2245        // The two flags default to the shape an ordinary whole-space offer
2246        // has, so a frame that omits them behaves like one. Getting this
2247        // backwards would silence the inference that carries a document a
2248        // peer has never seen.
2249        let parsed: SyncBody = serde_json::from_str(r#"{"k":"vv","docs":{}}"#).unwrap();
2250        match parsed {
2251            SyncBody::Versions {
2252                reply,
2253                partial,
2254                docs,
2255            } => {
2256                assert!(!reply);
2257                assert!(!partial);
2258                assert!(docs.is_empty());
2259            }
2260            other => panic!("expected a version frame, got {other:?}"),
2261        }
2262    }
2263
2264    #[test]
2265    fn a_snapshot_request_round_trips_and_carries_no_bytes() {
2266        // It names a document and nothing else: the request exists because
2267        // the asker cannot express its own gap, so anything it added would
2268        // be the thing that was already refused.
2269        let body = SyncBody::NeedSnapshot {
2270            doc: "notes".to_string(),
2271        };
2272        let json = serde_json::to_string(&body).unwrap();
2273        assert_eq!(json, r#"{"k":"need_snap","doc":"notes"}"#);
2274
2275        let parsed: SyncBody = serde_json::from_str(&json).unwrap();
2276        assert!(matches!(parsed, SyncBody::NeedSnapshot { doc } if doc == "notes"));
2277    }
2278
2279    #[test]
2280    fn digests_separate_blobs_and_repeat_for_the_same_one() {
2281        assert_eq!(blob_digest(b"one"), blob_digest(b"one"));
2282        assert_ne!(blob_digest(b"one"), blob_digest(b"two"));
2283        assert_eq!(blob_digest(b"one").len(), 32);
2284    }
2285}
2286
2287/// The frozen wire vectors for `__DATA_V1__`.
2288///
2289/// The conformance surface a second implementation is written against, and
2290/// the reason this layer is a protocol rather than an SDK feature. The file
2291/// was computed independently of the code it pins, so a mismatch is evidence
2292/// about the code rather than a disagreement between two copies of the same
2293/// mistake.
2294///
2295/// When one of these fails the wire format has changed. Bump the frame
2296/// version and negotiate a new `data_versions` entry. Do NOT edit the
2297/// expected values to make the test pass: every shipped install still speaks
2298/// the old ones, and a silent change here is a silent break there.
2299#[cfg(test)]
2300mod golden_vectors {
2301    use super::*;
2302
2303    const VECTORS: &str = include_str!("../../tests/data/data-sync-v1.vectors.json");
2304
2305    fn vectors() -> serde_json::Value {
2306        serde_json::from_str(VECTORS).expect("the vector file is JSON")
2307    }
2308
2309    /// Frame the way [`OfflineProtocol::send_sync_frame`] does, which is the
2310    /// only place the version is spliced in. Reproduced rather than called
2311    /// because calling it needs a whole protocol, and what is pinned here is
2312    /// the encoding, not the send path.
2313    fn framed(body: &SyncBody) -> String {
2314        let json = serde_json::to_string(body).expect("encode");
2315        format!(
2316            "{}{{\"v\":{},{}",
2317            internal_prefixes::DATA_V1,
2318            DATA_SYNC_V1,
2319            &json[1..]
2320        )
2321    }
2322
2323    fn wire(vectors: &serde_json::Value, name: &str) -> String {
2324        vectors["frames"]
2325            .as_array()
2326            .expect("frames")
2327            .iter()
2328            .find(|frame| frame["name"] == name)
2329            .unwrap_or_else(|| panic!("no vector named {name}"))["wire"]
2330            .as_str()
2331            .expect("wire is a string")
2332            .to_string()
2333    }
2334
2335    #[test]
2336    fn the_vector_file_is_the_size_it_was() {
2337        // A file silently shortened by a bad merge would make every
2338        // assertion below pass by not running.
2339        //
2340        // Every array, not only the frames. The rest are read by `for`
2341        // loops and by index, and a loop over an emptied array is the same
2342        // green test as a loop over a full one. Counting them here is what
2343        // makes "the vectors pass" mean the vectors ran.
2344        let vectors = vectors();
2345        let len = |name: &str| -> usize {
2346            vectors[name]
2347                .as_array()
2348                .unwrap_or_else(|| panic!("{name} must be an array"))
2349                .len()
2350        };
2351        assert_eq!(len("frames"), 8);
2352        assert_eq!(len("parse_defaults"), 1);
2353        assert_eq!(len("attachment_hashes"), 3);
2354        assert_eq!(len("attachment_references"), 2);
2355        assert_eq!(len("data_purposes"), 2);
2356        assert_eq!(len("blob_digests"), 1);
2357        assert_eq!(vectors["version"].as_u64(), Some(DATA_SYNC_V1 as u64));
2358        assert_eq!(vectors["prefix"].as_str(), Some(internal_prefixes::DATA_V1));
2359    }
2360
2361    #[test]
2362    fn every_frame_kind_encodes_to_its_vector() {
2363        let vectors = vectors();
2364
2365        let mut docs = BTreeMap::new();
2366        docs.insert("notes".to_string(), "AQID".to_string());
2367        assert_eq!(
2368            framed(&SyncBody::Versions {
2369                reply: false,
2370                partial: false,
2371                docs,
2372            }),
2373            wire(&vectors, "version offer, complete, asking")
2374        );
2375
2376        // Inserted out of order on purpose: the map is ordered, so the wire
2377        // does not depend on what order a sender happened to hold them in.
2378        let mut docs = BTreeMap::new();
2379        docs.insert("b".to_string(), "Zg==".to_string());
2380        docs.insert("a".to_string(), String::new());
2381        assert_eq!(
2382            framed(&SyncBody::Versions {
2383                reply: true,
2384                partial: true,
2385                docs,
2386            }),
2387            wire(&vectors, "version offer, answering leg")
2388        );
2389
2390        assert_eq!(
2391            framed(&SyncBody::Versions {
2392                reply: false,
2393                partial: false,
2394                docs: BTreeMap::new(),
2395            }),
2396            wire(&vectors, "version offer with no documents")
2397        );
2398
2399        let blob = BASE64.encode([0u8, 1, 2, 3, 4, 5, 6, 7]);
2400        assert_eq!(
2401            framed(&SyncBody::Delta {
2402                doc: "notes".to_string(),
2403                blob: blob.clone(),
2404            }),
2405            wire(&vectors, "delta")
2406        );
2407        assert_eq!(
2408            framed(&SyncBody::Snapshot {
2409                doc: "notes".to_string(),
2410                blob,
2411            }),
2412            wire(&vectors, "snapshot")
2413        );
2414        assert_eq!(
2415            framed(&SyncBody::NeedSnapshot {
2416                doc: "notes".to_string(),
2417            }),
2418            wire(&vectors, "snapshot request")
2419        );
2420
2421        let hash = vectors["attachment_hashes"][0]["sha256"]
2422            .as_str()
2423            .expect("hash")
2424            .to_string();
2425        assert_eq!(
2426            framed(&SyncBody::NeedBlob { hash: hash.clone() }),
2427            wire(&vectors, "blob request")
2428        );
2429        assert_eq!(
2430            framed(&SyncBody::BlobGone { hash }),
2431            wire(&vectors, "blob refusal")
2432        );
2433    }
2434
2435    #[test]
2436    fn every_vector_parses_back_into_the_kind_it_names() {
2437        // The other direction, and the one a second implementation's sender
2438        // is judged by. Encoding-only vectors would let a decoder drift.
2439        let vectors = vectors();
2440        for frame in vectors["frames"].as_array().expect("frames") {
2441            let wire = frame["wire"].as_str().expect("wire");
2442            let body = wire
2443                .strip_prefix(internal_prefixes::DATA_V1)
2444                .expect("every vector carries the marker");
2445            let value: serde_json::Value = serde_json::from_str(body).expect("the body is JSON");
2446            assert_eq!(
2447                value["v"].as_u64(),
2448                Some(DATA_SYNC_V1 as u64),
2449                "{}: version",
2450                frame["name"]
2451            );
2452            assert_eq!(
2453                value["k"].as_str(),
2454                frame["kind"].as_str(),
2455                "{}: kind",
2456                frame["name"]
2457            );
2458            let parsed: SyncBody =
2459                serde_json::from_value(value).expect("the body parses into a frame");
2460            let kind = match parsed {
2461                SyncBody::Versions { .. } => "vv",
2462                SyncBody::Delta { .. } => "delta",
2463                SyncBody::Snapshot { .. } => "snap",
2464                SyncBody::NeedSnapshot { .. } => "need_snap",
2465                SyncBody::NeedBlob { .. } => "blob",
2466                SyncBody::BlobGone { .. } => "gone",
2467            };
2468            let expected = match frame["kind"].as_str().expect("kind") {
2469                "need_blob" => "blob",
2470                "blob_gone" => "gone",
2471                other => other,
2472            };
2473            assert_eq!(kind, expected, "{}: round trip", frame["name"]);
2474        }
2475    }
2476
2477    #[test]
2478    fn absent_flags_parse_as_false() {
2479        let vectors = vectors();
2480        for case in vectors["parse_defaults"]
2481            .as_array()
2482            .expect("parse_defaults")
2483        {
2484            let value: serde_json::Value =
2485                serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
2486            let SyncBody::Versions { reply, partial, .. } =
2487                serde_json::from_value(value).expect("parses")
2488            else {
2489                panic!("{}: expected a version offer", case["name"]);
2490            };
2491            assert_eq!(reply, case["expect"]["reply"].as_bool().expect("reply"));
2492            assert_eq!(
2493                partial,
2494                case["expect"]["partial"].as_bool().expect("partial")
2495            );
2496        }
2497    }
2498
2499    #[test]
2500    fn attachment_hashes_match_their_vectors() {
2501        let vectors = vectors();
2502        for case in vectors["attachment_hashes"]
2503            .as_array()
2504            .expect("attachment_hashes")
2505        {
2506            let bytes = match (case.get("input_utf8"), case.get("input_hex")) {
2507                (Some(text), _) => text.as_str().expect("utf8").as_bytes().to_vec(),
2508                (_, Some(hex)) => {
2509                    let hex = hex.as_str().expect("hex");
2510                    (0..hex.len())
2511                        .step_by(2)
2512                        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
2513                        .collect()
2514                }
2515                _ => panic!("a hash vector needs an input"),
2516            };
2517            assert_eq!(
2518                bytes.len() as u64,
2519                case["size"].as_u64().expect("size"),
2520                "the vector's own size must describe its own input"
2521            );
2522            assert_eq!(
2523                OfflineProtocol::data_attachment_hash(&bytes),
2524                case["sha256"].as_str().expect("sha256").to_string()
2525            );
2526        }
2527    }
2528
2529    #[test]
2530    fn attachment_references_encode_to_their_vectors() {
2531        use offline_protocol_data::DataValue;
2532        let vectors = vectors();
2533        let hash = vectors["attachment_hashes"][0]["sha256"]
2534            .as_str()
2535            .expect("hash")
2536            .to_string();
2537        let size = vectors["attachment_hashes"][0]["size"]
2538            .as_u64()
2539            .expect("size");
2540
2541        let full = DataValue::Attachment {
2542            hash: hash.clone(),
2543            size,
2544            name: Some("plan.pdf".to_string()),
2545            mime: Some("application/pdf".to_string()),
2546        };
2547        assert_eq!(
2548            serde_json::to_string(&full).expect("encode"),
2549            vectors["attachment_references"][0]["json"]
2550                .as_str()
2551                .expect("json")
2552        );
2553
2554        // Absent, not null. A reader that has to tell one from the other has
2555        // been given two spellings of one value.
2556        let bare = DataValue::attachment(hash, size);
2557        assert_eq!(
2558            serde_json::to_string(&bare).expect("encode"),
2559            vectors["attachment_references"][1]["json"]
2560                .as_str()
2561                .expect("json")
2562        );
2563        assert!(!vectors["attachment_references"][1]["json"]
2564            .as_str()
2565            .expect("json")
2566            .contains("null"));
2567    }
2568
2569    #[test]
2570    fn data_purposes_encode_to_their_vectors() {
2571        let vectors = vectors();
2572        let hash = vectors["attachment_hashes"][0]["sha256"]
2573            .as_str()
2574            .expect("hash")
2575            .to_string();
2576        assert_eq!(
2577            serde_json::to_string(&DataPurpose::Attachment { hash }).expect("encode"),
2578            vectors["data_purposes"][0]["json"].as_str().expect("json")
2579        );
2580        assert_eq!(
2581            serde_json::to_string(&DataPurpose::Snapshot {
2582                doc: "notes".to_string(),
2583            })
2584            .expect("encode"),
2585            vectors["data_purposes"][1]["json"].as_str().expect("json")
2586        );
2587    }
2588
2589    #[test]
2590    fn blob_digests_match_their_vectors() {
2591        let vectors = vectors();
2592        for case in vectors["blob_digests"].as_array().expect("blob_digests") {
2593            let hex = case["input_hex"].as_str().expect("hex");
2594            let bytes: Vec<u8> = (0..hex.len())
2595                .step_by(2)
2596                .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
2597                .collect();
2598            assert_eq!(
2599                blob_digest(&bytes),
2600                case["digest"].as_str().expect("digest")
2601            );
2602        }
2603    }
2604}