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