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