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