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