use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use offline_protocol_data::{CatchUp, RemoteImport, VersionToken};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::{debug, warn};
use crate::error::{Error, Result};
use crate::events::Event;
use crate::media_envelope::DataPurpose;
use crate::protocol::prefixes::internal_prefixes;
use crate::protocol::types::{storage_keys, MediaSendOptions, DATA_SYNC_V1};
use crate::protocol::OfflineProtocol;
use offline_protocol_core::ContentType;
pub(crate) const MAX_SYNC_BLOB_BYTES: usize = 32 * 1024;
const MAX_DOCS_PER_VERSION_FRAME: usize = 128;
pub(crate) const DATA_SYNC_OFFER_INTERVAL: Duration = Duration::from_secs(30);
pub(crate) const DATA_SYNC_SETTLE_DELAY: Duration = Duration::from_secs(3);
pub(crate) const MAX_DOCS_PER_SPACE: usize = 1024;
pub(crate) const MAX_REMOVAL_FLOOR_BYTES: usize = 64 * 1024;
pub(crate) const MAX_QUARANTINED_BLOBS: usize = 32;
pub(crate) const MAX_MEDIA_SNAPSHOT_BYTES: usize = super::types::MAX_PROTOCOL_STATE_RECORD_BYTES;
pub(crate) const MAX_PENDING_ATTACHMENT_FETCHES: usize = 64;
pub(crate) const ATTACHMENT_FETCH_TIMEOUT: Duration = Duration::from_secs(15 * 60);
pub(crate) const MAX_BLOB_REQUESTS_PER_WINDOW: usize = 32;
pub(crate) const MAX_BLOB_REQUEST_WINDOWS: usize = 256;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "k")]
enum SyncBody {
#[serde(rename = "vv")]
Versions {
#[serde(default)]
reply: bool,
#[serde(default)]
partial: bool,
docs: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
gone: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
want: Option<Vec<String>>,
},
#[serde(rename = "delta")]
Delta { doc: String, blob: String },
#[serde(rename = "snap")]
Snapshot { doc: String, blob: String },
#[serde(rename = "need_snap")]
NeedSnapshot { doc: String },
#[serde(rename = "need_blob")]
NeedBlob {
hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<String>,
},
#[serde(rename = "blob_gone")]
BlobGone { hash: String },
#[serde(rename = "chunk")]
Chunk {
hash: String,
i: u32,
n: u32,
blob: String,
},
}
type OfferEntry = (String, Option<String>, Option<String>);
fn offer_batches(entries: &[OfferEntry]) -> Vec<&[OfferEntry]> {
let mut batches = Vec::new();
let mut start = 0usize;
let mut weight = 0usize;
for (index, (_, version, floor)) in entries.iter().enumerate() {
let cost = usize::from(version.is_some()) + usize::from(floor.is_some());
if index > start && weight + cost > MAX_DOCS_PER_VERSION_FRAME {
batches.push(&entries[start..index]);
start = index;
weight = 0;
}
weight += cost;
}
batches.push(&entries[start..]);
batches
}
#[derive(Debug)]
pub(crate) struct PendingFetch {
pub(crate) last_seen: Instant,
pub(crate) holder: Option<String>,
pub(crate) assembly: Option<BlobAssembly>,
}
impl PendingFetch {
fn to_peer() -> Self {
Self {
last_seen: Instant::now(),
holder: None,
assembly: None,
}
}
fn to_member(member: &str) -> Self {
Self {
last_seen: Instant::now(),
holder: Some(member.to_string()),
assembly: None,
}
}
fn is_answered_by(&self, space: &str, sender: &str) -> bool {
match &self.holder {
Some(member) => member == sender,
None => space == sender,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct BlobAssembly {
total: u32,
chunks: BTreeMap<u32, Vec<u8>>,
}
#[derive(Debug, Default)]
struct OfferContents {
docs: BTreeMap<String, String>,
gone: BTreeMap<String, String>,
want: Option<Vec<String>>,
}
const MAX_INTEREST_PATTERNS_PER_FRAME: usize = 32;
const _: () = assert!(
super::data::MAX_INTEREST_PATTERNS <= MAX_INTEREST_PATTERNS_PER_FRAME,
"a pattern an application may declare must be one every peer reads"
);
pub(crate) const MAX_GROUP_BLOB_CHUNKS: u32 = 32;
#[derive(Debug, Clone)]
pub(crate) enum SyncChannel {
Peer,
GroupBroadcast,
GroupDirected(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlobKind {
Delta,
Snapshot,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct SyncRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
in_flight: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
quarantined: Vec<String>,
}
pub(crate) fn blob_digest(blob: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(blob);
hex_encode(&hasher.finalize()[..16])
}
fn group_offer_key(member: &str, group_id: &str) -> String {
format!("{member}{GROUP_OFFER_KEY_SEP}{group_id}")
}
const GROUP_OFFER_KEY_SEP: char = '\x01';
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
impl OfflineProtocol {
pub(crate) fn kick_data_sync(&mut self, peer: &str, cause: &str) {
if !self.data_sync_active(peer) {
return;
}
if !self.data_sync_offer_due(peer) {
return;
}
self.offer_versions(peer, cause);
}
#[cfg_attr(not(feature = "data"), allow(dead_code))]
pub(crate) fn kick_group_data_sync(&mut self, group_id: &str, member: &str, cause: &str) {
let members = match self.group_roster(group_id) {
Ok(members) => members,
Err(_) => return,
};
if !self.group_data_sync_active(&members) {
self.probe_group_data_capabilities(group_id, &members);
return;
}
let key = group_offer_key(member, group_id);
if !self.data_sync_offer_due(&key) {
return;
}
self.offer_versions_over(
group_id,
&SyncChannel::GroupDirected(member.to_string()),
cause,
);
}
#[cfg_attr(not(feature = "data"), allow(dead_code))]
fn probe_group_data_capabilities(&mut self, space: &str, members: &[String]) {
let unknown = self.group_data_unknown_members(members);
if unknown.is_empty() {
return;
}
debug!(
space,
unknown = unknown.len(),
"Group replication is held closed by members of unknown capability; probing"
);
self.backfill_group_rich_capabilities(&unknown);
}
#[cfg(feature = "data")]
pub(crate) fn forget_data_sync_offer_windows(&mut self, peer: &str) {
self.last_data_sync_offer.retain(|key, _| {
key != peer
&& !key
.split_once(GROUP_OFFER_KEY_SEP)
.is_some_and(|(member, _)| member == peer)
});
}
fn data_sync_offer_due(&mut self, key: &str) -> bool {
if let Some(last) = self.last_data_sync_offer.get(key) {
if Instant::now().duration_since(*last) < DATA_SYNC_OFFER_INTERVAL {
return false;
}
}
self.last_data_sync_offer
.insert(key.to_string(), Instant::now());
true
}
#[cfg_attr(not(feature = "data"), allow(dead_code))]
pub(crate) fn kick_shared_group_data_sync(&mut self, peer: &str, cause: &str) {
if !self.config.data.enabled {
return;
}
self.enumerate_group_spaces_once();
let shared: Vec<String> = self
.group_mesh
.members
.iter()
.filter(|(_, members)| members.iter().any(|m| m == peer))
.map(|(group_id, _)| group_id.clone())
.collect();
for group_id in shared {
self.kick_group_data_sync(&group_id, peer, cause);
}
}
fn enumerate_group_spaces_once(&mut self) {
if self.group_spaces_enumerated {
return;
}
let Ok(groups) = self.list_groups() else {
return;
};
self.group_spaces_enumerated = true;
for group_id in groups {
if self.group_mesh.members.contains_key(&group_id) {
continue;
}
if let Err(err) = self.refresh_group_members(&group_id) {
debug!(group_id = %group_id, error = %err, "Group roster unreadable while enumerating group spaces");
}
}
}
pub(crate) fn nudge_data_sync(&mut self, space: &str, origin: Option<&str>, cause: &str) {
match self.space_channel(space, origin) {
Some(SyncChannel::Peer) => self.offer_versions(space, cause),
Some(channel @ SyncChannel::GroupBroadcast) => {
self.offer_versions_over(space, &channel, cause)
}
Some(SyncChannel::GroupDirected(_)) | None => {}
}
}
fn space_channel(&mut self, space: &str, origin: Option<&str>) -> Option<SyncChannel> {
if let Some(members) = self.group_space_roster(space) {
if origin.is_some() {
return None;
}
if self.group_data_sync_active(&members) {
return Some(SyncChannel::GroupBroadcast);
}
self.probe_group_data_capabilities(space, &members);
return None;
}
if origin == Some(space) || !self.data_sync_active(space) {
return None;
}
Some(SyncChannel::Peer)
}
fn offer_versions(&mut self, peer: &str, cause: &str) {
self.last_data_sync_offer
.insert(peer.to_string(), Instant::now());
self.offer_versions_over(peer, &SyncChannel::Peer, cause);
}
fn offer_versions_over(&mut self, space: &str, channel: &SyncChannel, cause: &str) {
let peer = space;
let docs = match self.data_sync_versions(peer) {
Ok(docs) => docs,
Err(err) => {
warn!(peer = %peer, cause, error = %err, "Could not read document versions");
return;
}
};
let gone = self.tombstones_for(peer, channel);
let want = self.interest_for(peer, channel);
debug!(space = %peer, cause, docs = docs.len(), gone = gone.len(), "Offering document versions");
self.send_version_frames(
peer,
channel,
OfferContents { docs, gone, want },
false,
false,
);
}
fn tombstones_for(&mut self, space: &str, channel: &SyncChannel) -> BTreeMap<String, String> {
if matches!(channel, SyncChannel::Peer) && !self.data_tombstones_active(space) {
return BTreeMap::new();
}
match self.data_sync_tombstones(space) {
Ok(gone) => gone,
Err(err) => {
warn!(space, error = %err, "Could not read this space's removals");
BTreeMap::new()
}
}
}
fn interest_for(&mut self, space: &str, channel: &SyncChannel) -> Option<Vec<String>> {
if matches!(channel, SyncChannel::Peer) && !self.data_interest_active(space) {
return None;
}
self.data_interest(space).cloned()
}
fn send_version_frames(
&mut self,
space: &str,
channel: &SyncChannel,
offer: OfferContents,
reply: bool,
force_partial: bool,
) {
let OfferContents { docs, gone, want } = offer;
if docs.is_empty() && gone.is_empty() && reply {
return;
}
let mut entries: Vec<OfferEntry> = Vec::new();
let mut removals = gone;
for (doc, version) in docs {
let floor = removals.remove(&doc);
entries.push((doc, Some(version), floor));
}
for (doc, floor) in removals {
entries.push((doc, None, Some(floor)));
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
let batches = offer_batches(&entries);
let partial = force_partial || batches.len() > 1;
let bodies: Vec<SyncBody> = batches
.into_iter()
.map(|batch| {
let mut batch_docs = BTreeMap::new();
let mut batch_gone = BTreeMap::new();
for (doc, version, floor) in batch {
if let Some(version) = version {
batch_docs.insert(doc.clone(), version.clone());
}
if let Some(floor) = floor {
batch_gone.insert(doc.clone(), floor.clone());
}
}
SyncBody::Versions {
reply,
partial,
docs: batch_docs,
gone: batch_gone,
want: want.clone(),
}
})
.collect();
for body in bodies {
self.send_sync_frame(space, channel, &body);
}
}
fn request_doc_catch_up(&mut self, space: &str, channel: &SyncChannel, doc: &str) {
let version = match self.data_doc_version(space, doc) {
Ok(version) => version,
Err(err) => {
warn!(space, doc, error = %err, "Could not read our version to ask for the gap");
return;
}
};
self.send_version_frames(
space,
channel,
OfferContents {
docs: BTreeMap::from([(doc.to_string(), version)]),
..OfferContents::default()
},
true,
true,
);
}
fn send_sync_frame(&mut self, space: &str, channel: &SyncChannel, body: &SyncBody) {
let plaintext = match serde_json::to_string(body) {
Ok(json) => format!(
"{}{{\"v\":{},{}",
internal_prefixes::DATA_V1,
DATA_SYNC_V1,
&json[1..]
),
Err(err) => {
warn!(space, error = %err, "Failed to encode sync frame");
return;
}
};
match channel {
SyncChannel::Peer => self.send_sync_frame_to_peer(space, plaintext),
SyncChannel::GroupBroadcast => {
if let Err(err) = self.send_group_internal_frame(space, None, &plaintext) {
debug!(space, error = %err, "Group replication frame not sent");
}
}
SyncChannel::GroupDirected(member) => {
let member = member.clone();
if let Err(err) = self.send_group_internal_frame(space, Some(&member), &plaintext) {
debug!(space, error = %err, "Directed replication frame not sent");
}
}
}
}
fn send_sync_frame_to_peer(&mut self, peer: &str, plaintext: String) {
let encrypted = match self.encrypt_content_for_recipient_strict(peer, &plaintext) {
Ok(content) => content,
Err(err) => {
debug!(peer = %peer, error = %err, "Sync frame skipped: no session");
return;
}
};
let message = match self.create_message(peer, encrypted, None, None) {
Ok(message) => message,
Err(err) => {
warn!(peer = %peer, error = %err, "Failed to build sync frame");
return;
}
};
if self.deduplicator.is_duplicate(&message.id) {
return;
}
self.deduplicator.mark_seen_local(message.id.clone());
let recipient = message.recipient.as_str().to_string();
self.stage_outbox_reseal(
&message.id,
&recipient,
crate::protocol::types::OutboxReseal {
content: plaintext,
priority: message.priority,
reply_to_msg: None,
forwarded_from: None,
content_type: message.content_type,
media_metadata: None,
rich: None,
seal_mark: None,
},
);
let previous_transport = self.transport_manager.current_transport();
match self.transport_manager.send(&message) {
Ok(()) => {
let current = self.transport_manager.current_transport();
let _ = self.handle_send_success(&message, current);
}
Err(err) => {
let current = self.transport_manager.current_transport();
let _ = self.handle_send_failure(&message, current.or(previous_transport));
debug!(peer = %peer, error = %err, "Sync frame deferred to the outbox");
}
}
}
pub(crate) fn push_data_delta(
&mut self,
space: &str,
doc: &str,
blob: &[u8],
origin: Option<&str>,
) {
let Some(channel) = self.space_channel(space, origin) else {
return;
};
if origin.is_none() {
self.arm_data_sync_settle(space, doc);
}
if blob.len() > MAX_SYNC_BLOB_BYTES {
debug!(
space,
doc,
bytes = blob.len(),
"Change too large to push inline; offering versions instead"
);
self.nudge_data_sync(space, origin, "oversized_delta");
return;
}
self.send_sync_frame(
space,
&channel,
&SyncBody::Delta {
doc: doc.to_string(),
blob: BASE64.encode(blob),
},
);
}
fn arm_data_sync_settle(&mut self, space: &str, doc: &str) {
let due = Instant::now() + DATA_SYNC_SETTLE_DELAY;
let (at, docs) = self
.data_sync_settle
.entry(space.to_string())
.or_insert_with(|| (due, BTreeSet::new()));
*at = due;
docs.insert(doc.to_string());
}
pub(crate) fn send_due_data_settle_offers(&mut self, now: Instant) {
let due: Vec<String> = self
.data_sync_settle
.iter()
.filter(|(_, (at, _))| *at <= now)
.map(|(space, _)| space.clone())
.collect();
for space in due {
let Some((_, docs)) = self.data_sync_settle.remove(&space) else {
continue;
};
let Some(channel) = self.space_channel(&space, None) else {
continue;
};
let mut versions = BTreeMap::new();
for doc in docs {
if !self.data_holds_doc(&space, &doc) {
continue;
}
match self.data_doc_version(&space, &doc) {
Ok(version) => {
versions.insert(doc, version);
}
Err(err) => {
warn!(space, doc, error = %err, "Could not read a version to settle");
}
}
}
if versions.is_empty() {
continue;
}
debug!(
space,
cause = "settle",
docs = versions.len(),
"Local commits went quiet; offering their versions"
);
self.send_version_frames(
&space,
&channel,
OfferContents {
docs: versions,
..OfferContents::default()
},
false,
true,
);
}
}
pub(crate) fn handle_data_sync_frame(&mut self, sender: &str, body: &str) {
self.handle_data_sync_frame_over(sender, &SyncChannel::Peer, sender, body);
}
#[cfg_attr(not(feature = "data"), allow(dead_code))]
pub(crate) fn handle_group_data_sync_frame(
&mut self,
group_id: &str,
sender: &str,
body: &str,
) {
self.handle_data_sync_frame_over(
group_id,
&SyncChannel::GroupDirected(sender.to_string()),
sender,
body,
);
}
fn handle_data_sync_frame_over(
&mut self,
space: &str,
channel: &SyncChannel,
sender: &str,
body: &str,
) {
if !self.config.data.enabled {
debug!(peer = %sender, "Sync frame dropped: the data layer is off");
return;
}
let value: serde_json::Value = match serde_json::from_str(body) {
Ok(value) => value,
Err(err) => {
warn!(peer = %sender, error = %err, "Malformed sync frame");
return;
}
};
match value.get("v").and_then(serde_json::Value::as_u64) {
Some(v) if v == DATA_SYNC_V1 as u64 => {}
other => {
debug!(peer = %sender, version = ?other, "Sync frame from an unsupported version");
return;
}
}
let frame: SyncBody = match serde_json::from_value(value) {
Ok(frame) => frame,
Err(err) => {
warn!(peer = %sender, error = %err, "Unreadable sync frame body");
return;
}
};
let space = space.to_string();
let outcome = match frame {
SyncBody::Versions {
reply,
partial,
docs,
gone,
want,
} => self.answer_version_offer(
&space,
channel,
reply,
partial,
OfferContents { docs, gone, want },
),
SyncBody::Delta { doc, blob } => {
self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Delta)
}
SyncBody::Snapshot { doc, blob } => {
self.accept_remote_blob(&space, channel, &doc, &blob, BlobKind::Snapshot)
}
SyncBody::NeedSnapshot { doc } => self.answer_snapshot_request(&space, channel, &doc),
SyncBody::NeedBlob { hash, to } => {
self.answer_blob_request(&space, channel, &hash, to.as_deref())
}
SyncBody::BlobGone { hash } => self.report_blob_gone(&space, sender, &hash),
SyncBody::Chunk { .. } if matches!(channel, SyncChannel::Peer) => {
warn!(
peer = %sender,
"A blob chunk arrived on a 1:1 session, where bytes ride the media path"
);
Ok(())
}
SyncBody::Chunk { hash, i, n, blob } => {
self.accept_blob_chunk(&space, sender, &hash, i, n, &blob)
}
};
if let Err(err) = outcome {
warn!(peer = %sender, error = %err, "Sync frame could not be handled");
}
}
fn answer_version_offer(
&mut self,
space: &str,
channel: &SyncChannel,
reply: bool,
partial: bool,
offer: OfferContents,
) -> Result<()> {
let OfferContents {
docs: theirs,
gone,
want,
} = offer;
let wanted = want.map(|mut patterns| {
if patterns.len() > MAX_INTEREST_PATTERNS_PER_FRAME {
warn!(
space,
patterns = patterns.len(),
"A version offer declares more interest patterns than are read"
);
patterns.truncate(MAX_INTEREST_PATTERNS_PER_FRAME);
}
patterns
});
let asked_for = |doc: &str| match &wanted {
None => true,
Some(patterns) => patterns
.iter()
.any(|pattern| super::data::interest_matches(pattern, doc)),
};
for (doc, encoded) in &gone {
if offline_protocol_data::validate_name(doc).is_err() {
warn!(space, doc, "A removal names an invalid document");
continue;
}
if encoded.len() > MAX_REMOVAL_FLOOR_BYTES * 4 / 3 + 4 {
warn!(space, doc, "Oversized removal floor in a sync frame");
continue;
}
if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
warn!(
space,
doc,
cap = MAX_DOCS_PER_SPACE,
"Space is at its document ceiling; ignoring a removal for a new name"
);
continue;
}
let floor = match BASE64.decode(encoded) {
Ok(bytes) => VersionToken::from_bytes(bytes),
Err(err) => {
warn!(space, doc, error = %err, "Undecodable removal floor in a sync frame");
continue;
}
};
match self.data_apply_tombstone(space, doc, &floor) {
Ok(outcome) => {
debug!(space, doc, ?outcome, "Applied a removal from a peer");
}
Err(err) => {
warn!(space, doc, error = %err, "Could not apply a removal from a peer");
}
}
}
let mut ours = self.data_sync_versions(space).unwrap_or_default();
let mut created = BTreeMap::new();
for (doc, theirs_encoded) in &theirs {
if ours.contains_key(doc) || offline_protocol_data::validate_name(doc).is_err() {
continue;
}
if self.version_is_removed_content(space, doc, theirs_encoded) {
debug!(space, doc, "Ignoring an offer of content a removal covers");
continue;
}
if !self.data_wants(space, doc) {
debug!(
space,
doc, "Not creating a document outside this space's interest"
);
continue;
}
if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
warn!(
space,
cap = MAX_DOCS_PER_SPACE,
"Space is at its document ceiling; ignoring the rest of the offer"
);
break;
}
if self.data_create_doc(space, doc).is_err() {
continue;
}
match self.data_doc_version(space, doc) {
Ok(version) => {
ours.insert(doc.clone(), version.clone());
created.insert(doc.clone(), version);
}
Err(err) => {
warn!(space, doc, error = %err, "Created a document from an offer but cannot read its version");
}
}
}
for (doc, ours_encoded) in &ours {
if !asked_for(doc) {
continue;
}
let Some(theirs_encoded) = theirs.get(doc) else {
if !partial {
self.offer_catch_up(space, channel, doc, None);
}
continue;
};
if theirs_encoded == ours_encoded {
continue;
}
match BASE64.decode(theirs_encoded) {
Ok(token) => {
self.offer_catch_up(space, channel, doc, Some(VersionToken::from_bytes(token)))
}
Err(err) => {
warn!(space, doc, error = %err, "Undecodable version token in a sync frame");
}
}
}
if !reply {
let our_gone = self.tombstones_for(space, channel);
let our_want = self.interest_for(space, channel);
self.send_version_frames(
space,
channel,
OfferContents {
docs: ours,
gone: our_gone,
want: our_want,
},
true,
false,
);
} else {
self.send_version_frames(
space,
channel,
OfferContents {
docs: created,
..OfferContents::default()
},
true,
true,
);
}
Ok(())
}
fn version_is_removed_content(&mut self, space: &str, doc: &str, encoded: &str) -> bool {
match BASE64.decode(encoded) {
Ok(bytes) => {
self.data_version_below_floor(space, doc, &VersionToken::from_bytes(bytes))
}
Err(_) => false,
}
}
fn offer_catch_up(
&mut self,
space: &str,
channel: &SyncChannel,
doc: &str,
theirs: Option<VersionToken>,
) {
if let Some(token) = theirs {
match self.data_catch_up(space, doc, &token) {
Ok(CatchUp::UpToDate) => return,
Ok(CatchUp::Updates(bytes)) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
self.send_sync_frame(
space,
channel,
&SyncBody::Delta {
doc: doc.to_string(),
blob: BASE64.encode(bytes),
},
);
return;
}
Ok(_) => {}
Err(err) => {
warn!(space, doc, error = %err, "Could not compute catch-up");
return;
}
}
}
self.send_snapshot(space, channel, doc);
}
fn send_snapshot(&mut self, space: &str, channel: &SyncChannel, doc: &str) {
match self.data_export_snapshot(space, doc) {
Ok(bytes) if bytes.len() <= MAX_SYNC_BLOB_BYTES => {
self.send_sync_frame(
space,
channel,
&SyncBody::Snapshot {
doc: doc.to_string(),
blob: BASE64.encode(bytes),
},
);
}
Ok(bytes) => self.carry_snapshot_over_media(space, channel, doc, bytes),
Err(err) => {
warn!(space, doc, error = %err, "Could not export document for catch-up");
}
}
}
fn carry_snapshot_over_media(
&mut self,
space: &str,
channel: &SyncChannel,
doc: &str,
bytes: Vec<u8>,
) {
let size = bytes.len();
let report = |protocol: &mut Self, reason: &str| {
warn!(
space,
doc,
bytes = size,
reason,
"Document cannot be replicated"
);
if let Ok(state) = crate::protocol::lock_shared_state(&protocol.shared_state) {
state.emit_event(Event::DataDocUnsyncable {
space_id: space.to_string(),
doc_id: doc.to_string(),
bytes: size as u64,
reason: reason.to_string(),
});
}
};
if !matches!(channel, SyncChannel::Peer) {
report(self, "group_space_has_no_media_path");
return;
}
if size > MAX_MEDIA_SNAPSHOT_BYTES {
report(self, "over_record_ceiling");
return;
}
if !self.data_media_active(space) {
report(self, "peer_cannot_carry_snapshots");
return;
}
let purpose = DataPurpose::Snapshot {
doc: doc.to_string(),
};
if self.data_media_transfer_in_flight(space, &purpose) {
debug!(
space,
doc, "This document is already crossing to the peer; not starting a second copy"
);
return;
}
debug!(
space,
doc,
bytes = size,
"Carrying a snapshot over the media path"
);
if let Err(err) = self.send_media_inner(
space.to_string(),
bytes,
doc.to_string(),
ContentType::File,
MediaSendOptions::default(),
Some(purpose),
) {
debug!(space, doc, error = %err, "Snapshot carriage deferred");
}
}
fn answer_snapshot_request(
&mut self,
space: &str,
channel: &SyncChannel,
doc: &str,
) -> Result<()> {
if offline_protocol_data::validate_name(doc).is_err() {
warn!(space, doc, "Snapshot request names an invalid document");
return Ok(());
}
if !self.data_holds_doc(space, doc) {
debug!(space, doc, "Snapshot request for a document we do not hold");
return Ok(());
}
debug!(space, doc, "Serving a snapshot the peer asked for");
self.send_snapshot(space, channel, doc);
Ok(())
}
fn accept_remote_blob(
&mut self,
space: &str,
channel: &SyncChannel,
doc: &str,
encoded: &str,
kind: BlobKind,
) -> Result<()> {
if offline_protocol_data::validate_name(doc).is_err() {
warn!(space, doc, "Sync frame names an invalid document");
return Ok(());
}
if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
warn!(
space,
doc,
cap = MAX_DOCS_PER_SPACE,
"Space is at its document ceiling; refusing a new one"
);
return Ok(());
}
if encoded.len() > MAX_SYNC_BLOB_BYTES * 4 / 3 + 4 {
warn!(space, doc, "Sync frame carries an oversized blob");
return Ok(());
}
let blob = match BASE64.decode(encoded) {
Ok(blob) => blob,
Err(err) => {
warn!(space, doc, error = %err, "Undecodable blob in a sync frame");
return Ok(());
}
};
if blob.len() > MAX_SYNC_BLOB_BYTES {
warn!(
space,
doc,
bytes = blob.len(),
"Sync blob over the frame budget"
);
return Ok(());
}
self.apply_contained_blob(space, channel, doc, blob, kind)
}
fn apply_contained_blob(
&mut self,
space: &str,
channel: &SyncChannel,
doc: &str,
blob: Vec<u8>,
kind: BlobKind,
) -> Result<()> {
if offline_protocol_data::validate_name(doc).is_err() {
warn!(space, doc, "A remote blob names an invalid document");
return Ok(());
}
if !self.data_wants(space, doc) {
debug!(
space,
doc, "Refusing a blob for a document outside this space's interest"
);
return Ok(());
}
if !self.data_space_admits_doc(space, doc, MAX_DOCS_PER_SPACE) {
warn!(
space,
doc,
cap = MAX_DOCS_PER_SPACE,
"Space is at its document ceiling; refusing a new one"
);
return Ok(());
}
let digest = blob_digest(&blob);
let mut record = self.load_sync_record(space);
if record.quarantined.contains(&digest) {
warn!(space, doc, "Refusing a quarantined blob");
return Ok(());
}
if self.data_doc_has_floor(space, doc) {
record.in_flight = Some(digest.clone());
self.persist_sync_record(space, &record);
let below_floor = self.data_blob_below_floor(space, doc, &blob);
record.in_flight = None;
self.persist_sync_record(space, &record);
if below_floor {
debug!(
space,
doc, "Refusing a blob that carries only content a removal covers"
);
return Ok(());
}
}
if let Err(err) = self.data_revive_removed_doc(space, doc) {
warn!(space, doc, error = %err, "Could not bring back a removed document for a change that outlives its removal");
return Ok(());
}
let mut preflush_left_edits_pending = false;
match self.data_flush(space, doc) {
Ok(()) => {}
Err(Error::DocTooLarge { .. }) => {
debug!(
space,
doc,
"Local edits were flushed and pushed before applying a remote change; the \
document is over its cap and refuses further growth"
);
}
Err(err) => {
warn!(
space,
doc,
error = %err,
"Could not flush local edits before applying a remote change; they may \
still be pending"
);
preflush_left_edits_pending = true;
}
}
record.in_flight = Some(digest.clone());
self.persist_sync_record(space, &record);
let outcome = self.data_apply_remote(space, doc, &blob);
record.in_flight = None;
self.persist_sync_record(space, &record);
let applied = matches!(outcome, Ok(RemoteImport::Applied));
match outcome {
Ok(RemoteImport::Applied) => {
debug!(space, doc, "Applied a remote change");
}
Ok(RemoteImport::AlreadyHave) => {}
Ok(RemoteImport::Parked) => match kind {
BlobKind::Delta => {
debug!(space, doc, "Remote change parked; asking for the gap");
self.request_doc_catch_up(space, channel, doc);
}
BlobKind::Snapshot => {
warn!(
space,
doc,
"A snapshot from the peer did not apply; these replicas cannot be \
reconciled inline"
);
}
},
Ok(RemoteImport::RefusedTrimmedHistory) => match kind {
BlobKind::Delta => {
debug!(space, doc, "Remote change needs a snapshot; asking for one");
self.send_sync_frame(
space,
channel,
&SyncBody::NeedSnapshot {
doc: doc.to_string(),
},
);
}
BlobKind::Snapshot => {
warn!(
space,
doc,
"A snapshot from the peer cannot merge into this replica; the two \
have diverged past what replication can reconcile"
);
}
},
Err(err) => match kind {
BlobKind::Delta => {
warn!(space, doc, error = %err, "Remote change refused");
}
BlobKind::Snapshot => {
warn!(space, doc, error = %err, "A snapshot from the peer was unreadable");
}
},
}
if preflush_left_edits_pending && applied {
self.nudge_data_sync(space, None, "preflush_failure");
}
Ok(())
}
fn attachment_fetch_key(space: &str, hash: &str) -> String {
format!("{space}{GROUP_OFFER_KEY_SEP}{hash}")
}
pub fn data_attachment_hash(bytes: &[u8]) -> String {
hex_encode(&Sha256::digest(bytes))
}
pub fn data_fetch_attachment(&mut self, space: &str, hash: &str) -> Result<()> {
if !self.config.data.enabled {
return Err(Error::DataDisabled);
}
Self::validate_attachment_hash(hash)?;
if self.group_space_roster(space).is_some() {
return Err(Error::InvalidArgument(format!(
"space {space} is a group: a reference names bytes and not who holds them, so \
ask a member with data_fetch_attachment_from"
)));
}
if !self.data_media_active(space) {
return Err(Error::InvalidArgument(format!(
"peer {space} did not advertise attachment carriage"
)));
}
let key = Self::attachment_fetch_key(space, hash);
self.expire_attachment_fetches();
if let Some(pending) = self.pending_attachment_fetches.get(&key) {
if Instant::now().duration_since(pending.last_seen) < DATA_SYNC_OFFER_INTERVAL {
debug!(
space,
"Blob fetch repeated inside its window; already asked"
);
return Ok(());
}
}
if self.pending_attachment_fetches.len() >= MAX_PENDING_ATTACHMENT_FETCHES
&& !self.pending_attachment_fetches.contains_key(&key)
{
if let Some(oldest) = self
.pending_attachment_fetches
.iter()
.min_by_key(|(_, pending)| pending.last_seen)
.map(|(key, _)| key.clone())
{
let evicted = self.pending_attachment_fetches.remove(&oldest);
Self::report_fetch_ended(
&self.shared_state,
&oldest,
evicted
.as_ref()
.and_then(|pending| pending.holder.as_deref()),
"evicted",
);
}
}
self.pending_attachment_fetches
.insert(key, PendingFetch::to_peer());
self.send_sync_frame(
space,
&SyncChannel::Peer,
&SyncBody::NeedBlob {
hash: hash.to_string(),
to: None,
},
);
Ok(())
}
#[cfg(test)]
pub(crate) fn send_chunk_for_test(
&mut self,
space: &str,
channel: &SyncChannel,
hash: &str,
index: u32,
total: u32,
blob: &str,
) {
self.send_sync_frame(
space,
channel,
&SyncBody::Chunk {
hash: hash.to_string(),
i: index,
n: total,
blob: blob.to_string(),
},
);
}
fn accept_blob_chunk(
&mut self,
space: &str,
sender: &str,
hash: &str,
index: u32,
total: u32,
encoded: &str,
) -> Result<()> {
if Self::validate_attachment_hash(hash).is_err() {
warn!(space, "A blob chunk names something that is not a hash");
return Ok(());
}
if total == 0 || total > MAX_GROUP_BLOB_CHUNKS || index >= total {
warn!(
space,
index, total, "A blob chunk declares a shape this layer does not carry"
);
return Ok(());
}
let key = Self::attachment_fetch_key(space, hash);
let Some(pending) = self.pending_attachment_fetches.get_mut(&key) else {
debug!(space, "Discarding a blob chunk nothing asked for");
return Ok(());
};
if !pending.is_answered_by(space, sender) {
warn!(
space,
sender, "Discarding a blob chunk from a member this fetch was not put to"
);
return Ok(());
}
let bytes = match BASE64.decode(encoded) {
Ok(bytes) => bytes,
Err(err) => {
warn!(space, error = %err, "Undecodable blob chunk");
return Ok(());
}
};
if bytes.len() > MAX_SYNC_BLOB_BYTES {
warn!(
space,
bytes = bytes.len(),
"A blob chunk is over the frame budget"
);
return Ok(());
}
pending.last_seen = Instant::now();
let assembly = pending.assembly.get_or_insert_with(BlobAssembly::default);
if assembly.total == 0 {
assembly.total = total;
} else if assembly.total != total {
warn!(
space,
declared = assembly.total,
arriving = total,
"A blob chunk contradicts the count its siblings declared"
);
return Ok(());
}
assembly.chunks.insert(index, bytes);
if (assembly.chunks.len() as u32) < assembly.total {
return Ok(());
}
let assembled: Vec<u8> = assembly
.chunks
.values()
.flat_map(|chunk| chunk.iter().copied())
.collect();
let asked = self
.pending_attachment_fetches
.remove(&key)
.and_then(|pending| pending.holder);
let actual = Self::data_attachment_hash(&assembled);
if actual != hash {
warn!(
space,
sender, "Assembled blob does not hash to what was asked for"
);
Self::report_fetch_ended(&self.shared_state, &key, asked.as_deref(), "hash_mismatch");
return Ok(());
}
if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
state.emit_event(Event::DataAttachmentReceived {
space_id: space.to_string(),
peer_id: sender.to_string(),
hash: hash.to_string(),
data: BASE64.encode(&assembled),
});
}
Ok(())
}
pub fn data_fetch_attachment_from(
&mut self,
space: &str,
peer: &str,
hash: &str,
) -> Result<()> {
if !self.config.data.enabled {
return Err(Error::DataDisabled);
}
Self::validate_attachment_hash(hash)?;
let Some(members) = self.group_space_roster(space) else {
return Err(Error::InvalidArgument(format!(
"space {space} is not a group; use data_fetch_attachment"
)));
};
if !members.iter().any(|member| member == peer) {
return Err(Error::InvalidArgument(format!(
"{peer} is not a member of {space}"
)));
}
if peer == self.local_id {
return Err(Error::InvalidArgument(
"a group fetch names another member to ask, and a device is not a peer of itself"
.to_string(),
));
}
if !self.data_group_frames_known_received(peer) {
return Err(Error::InvalidArgument(format!(
"nothing is known about whether member {peer} receives replication \
frames inside a group, and a question they cannot intercept would \
be shown to them as text"
)));
}
if self.data_group_blob_known_absent(peer) {
return Err(Error::InvalidArgument(format!(
"member {peer} does not carry attachment bytes inside a group"
)));
}
let key = Self::attachment_fetch_key(space, hash);
self.expire_attachment_fetches();
match self.pending_attachment_fetches.get(&key) {
Some(pending)
if pending.holder.as_deref() == Some(peer)
&& Instant::now().duration_since(pending.last_seen)
< DATA_SYNC_OFFER_INTERVAL =>
{
debug!(
space,
peer, "Blob fetch repeated inside its window; already asked"
);
return Ok(());
}
Some(pending) if pending.holder.as_deref() != Some(peer) => {
let displaced = self.pending_attachment_fetches.remove(&key);
Self::report_fetch_ended(
&self.shared_state,
&key,
displaced
.as_ref()
.and_then(|pending| pending.holder.as_deref()),
"evicted",
);
}
_ => {}
}
if self.pending_attachment_fetches.len() >= MAX_PENDING_ATTACHMENT_FETCHES
&& !self.pending_attachment_fetches.contains_key(&key)
{
if let Some(oldest) = self
.pending_attachment_fetches
.iter()
.min_by_key(|(_, pending)| pending.last_seen)
.map(|(key, _)| key.clone())
{
let evicted = self.pending_attachment_fetches.remove(&oldest);
Self::report_fetch_ended(
&self.shared_state,
&oldest,
evicted
.as_ref()
.and_then(|pending| pending.holder.as_deref()),
"evicted",
);
}
}
self.pending_attachment_fetches
.insert(key, PendingFetch::to_member(peer));
self.send_sync_frame(
space,
&SyncChannel::GroupDirected(peer.to_string()),
&SyncBody::NeedBlob {
hash: hash.to_string(),
to: Some(peer.to_string()),
},
);
Ok(())
}
pub fn data_provide_attachment(
&mut self,
space: &str,
peer: &str,
hash: &str,
bytes: Vec<u8>,
) -> Result<()> {
if !self.config.data.enabled {
return Err(Error::DataDisabled);
}
Self::validate_attachment_hash(hash)?;
if bytes.is_empty() {
return Err(Error::InvalidArgument(
"an attachment must have bytes".to_string(),
));
}
let group = self.group_space_roster(space);
if group.is_none() && space != peer {
return Err(Error::InvalidArgument(format!(
"space {space} is not the 1:1 space with {peer}"
)));
}
if let Some(members) = &group {
if !members.iter().any(|member| member == peer) {
return Err(Error::InvalidArgument(format!(
"{peer} is not a member of {space}"
)));
}
}
let actual = Self::data_attachment_hash(&bytes);
if actual != hash {
return Err(Error::InvalidArgument(format!(
"these bytes hash to {actual}, not to {hash}"
)));
}
if group.is_some() {
let chunks = bytes.len().div_ceil(MAX_SYNC_BLOB_BYTES);
if chunks > MAX_GROUP_BLOB_CHUNKS as usize {
return Err(Error::InvalidArgument(format!(
"an attachment carried inside a group is at most {} bytes, and these are {}: \
send it over a 1:1 session instead",
MAX_GROUP_BLOB_CHUNKS as usize * MAX_SYNC_BLOB_BYTES,
bytes.len()
)));
}
let channel = SyncChannel::GroupDirected(peer.to_string());
for (index, chunk) in bytes.chunks(MAX_SYNC_BLOB_BYTES).enumerate() {
self.send_sync_frame(
space,
&channel,
&SyncBody::Chunk {
hash: hash.to_string(),
i: index as u32,
n: chunks as u32,
blob: BASE64.encode(chunk),
},
);
}
return Ok(());
}
let purpose = DataPurpose::Attachment {
hash: hash.to_string(),
};
if self.data_media_transfer_in_flight(peer, &purpose) {
debug!(
peer,
"These bytes are already crossing to this peer; not starting a second copy"
);
return Ok(());
}
self.send_media_inner(
peer.to_string(),
bytes,
hash.to_string(),
ContentType::File,
MediaSendOptions::default(),
Some(purpose),
)?;
Ok(())
}
pub fn data_decline_attachment(&mut self, space: &str, peer: &str, hash: &str) -> Result<()> {
if !self.config.data.enabled {
return Err(Error::DataDisabled);
}
Self::validate_attachment_hash(hash)?;
let group = self.group_space_roster(space);
if group.is_none() && space != peer {
return Err(Error::InvalidArgument(format!(
"space {space} is not the 1:1 space with {peer}"
)));
}
let channel = match &group {
Some(members) => {
if !members.iter().any(|member| member == peer) {
return Err(Error::InvalidArgument(format!(
"{peer} is not a member of {space}"
)));
}
SyncChannel::GroupDirected(peer.to_string())
}
None => {
if !self.data_media_active(peer) {
return Err(Error::InvalidArgument(format!(
"peer {peer} did not advertise attachment carriage"
)));
}
SyncChannel::Peer
}
};
self.send_sync_frame(
space,
&channel,
&SyncBody::BlobGone {
hash: hash.to_string(),
},
);
Ok(())
}
fn blob_request_due(&mut self, peer: &str, hash: &str) -> bool {
let now = Instant::now();
self.blob_request_windows
.retain(|_, seen| now.duration_since(*seen) < DATA_SYNC_OFFER_INTERVAL);
let key = format!("{peer}{GROUP_OFFER_KEY_SEP}{hash}");
if self.blob_request_windows.contains_key(&key) {
debug!(peer, "Blob request repeated inside its window; ignoring");
return false;
}
let live_for_peer = self
.blob_request_windows
.keys()
.filter(|key| {
key.split_once(GROUP_OFFER_KEY_SEP)
.is_some_and(|(asker, _)| asker == peer)
})
.count();
if live_for_peer >= MAX_BLOB_REQUESTS_PER_WINDOW {
warn!(
peer,
budget = MAX_BLOB_REQUESTS_PER_WINDOW,
"Peer is over its blob-request budget; ignoring"
);
return false;
}
if self.blob_request_windows.len() >= MAX_BLOB_REQUEST_WINDOWS {
if let Some(oldest) = self
.blob_request_windows
.iter()
.min_by_key(|(_, seen)| **seen)
.map(|(key, _)| key.clone())
{
self.blob_request_windows.remove(&oldest);
}
}
self.blob_request_windows.insert(key, now);
true
}
fn validate_attachment_hash(hash: &str) -> Result<()> {
offline_protocol_data::validate_attachment_hash(hash)
.map_err(|err| Error::InvalidArgument(err.to_string()))
}
pub(crate) fn awaiting_attachment(&self, peer: &str, hash: &str) -> bool {
self.pending_attachment_fetches
.contains_key(&Self::attachment_fetch_key(peer, hash))
}
pub(crate) fn forget_attachment_state(&mut self, peer: &str) {
let prefix = Self::attachment_fetch_key(peer, "");
let ended: Vec<(String, Option<String>)> = self
.pending_attachment_fetches
.iter()
.filter(|(key, pending)| {
key.starts_with(&prefix) || pending.holder.as_deref() == Some(peer)
})
.map(|(key, pending)| (key.clone(), pending.holder.clone()))
.collect();
for (key, holder) in ended {
self.pending_attachment_fetches.remove(&key);
Self::report_fetch_ended(&self.shared_state, &key, holder.as_deref(), "peer_gone");
}
self.blob_request_windows
.retain(|key, _| !key.starts_with(&prefix));
}
pub(crate) fn end_every_pending_fetch(&mut self) {
for (key, pending) in std::mem::take(&mut self.pending_attachment_fetches) {
Self::report_fetch_ended(
&self.shared_state,
&key,
pending.holder.as_deref(),
"peer_gone",
);
}
}
fn report_fetch_ended(
shared_state: &std::sync::Arc<std::sync::Mutex<crate::protocol::SharedState>>,
key: &str,
holder: Option<&str>,
reason: &str,
) {
let Some((space, hash)) = key.split_once(GROUP_OFFER_KEY_SEP) else {
return;
};
debug!(space, reason, "A blob fetch ended with no bytes");
if let Ok(state) = crate::protocol::lock_shared_state(shared_state) {
state.emit_event(Event::DataAttachmentUnavailable {
space_id: space.to_string(),
peer_id: holder.unwrap_or(space).to_string(),
hash: hash.to_string(),
reason: reason.to_string(),
});
}
}
pub(crate) fn refresh_attachment_fetch(&mut self, peer: &str, purpose: &DataPurpose) {
let DataPurpose::Attachment { hash } = purpose else {
return;
};
let key = Self::attachment_fetch_key(peer, hash);
if let Some(seen_at) = self.pending_attachment_fetches.get_mut(&key) {
seen_at.last_seen = Instant::now();
}
}
fn data_media_transfer_in_flight(&self, recipient: &str, purpose: &DataPurpose) -> bool {
self.outbound_media_transfers.values().any(|transfer| {
transfer.recipient == recipient && transfer.data_purpose.as_ref() == Some(purpose)
})
}
pub(crate) fn expire_attachment_fetches(&mut self) {
let now = Instant::now();
let expired: Vec<(String, Option<String>)> = self
.pending_attachment_fetches
.iter()
.filter(|(_, pending)| {
now.duration_since(pending.last_seen) >= ATTACHMENT_FETCH_TIMEOUT
})
.map(|(key, pending)| (key.clone(), pending.holder.clone()))
.collect();
for (key, holder) in expired {
self.pending_attachment_fetches.remove(&key);
Self::report_fetch_ended(&self.shared_state, &key, holder.as_deref(), "timeout");
}
}
fn answer_blob_request(
&mut self,
space: &str,
channel: &SyncChannel,
hash: &str,
to: Option<&str>,
) -> Result<()> {
if Self::validate_attachment_hash(hash).is_err() {
warn!(space, "Blob request names something that is not a hash");
return Ok(());
}
let asker = match channel {
SyncChannel::Peer => space.to_string(),
SyncChannel::GroupDirected(member) => member.clone(),
SyncChannel::GroupBroadcast => {
warn!(
space,
"A blob request arrived addressed to the whole roster; ignoring"
);
return Ok(());
}
};
if !matches!(channel, SyncChannel::Peer) {
match to {
Some(member) if member == self.local_id => {}
Some(_) => {
debug!(
space,
"A blob request names another member; not ours to answer"
);
return Ok(());
}
None => {
warn!(
space,
"A blob request inside a group names no member; ignoring"
);
return Ok(());
}
}
}
let answerable = match channel {
SyncChannel::Peer => self.data_media_active(space),
_ => self.config.data.enabled,
};
if !answerable {
warn!(
space,
"A blob request came from a peer that never advertised carriage; ignoring"
);
return Ok(());
}
if !self.blob_request_due(&asker, hash) {
return Ok(());
}
if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
state.emit_event(Event::DataAttachmentRequested {
space_id: space.to_string(),
peer_id: asker,
hash: hash.to_string(),
});
}
Ok(())
}
fn report_blob_gone(&mut self, space: &str, sender: &str, hash: &str) -> Result<()> {
if Self::validate_attachment_hash(hash).is_err() {
warn!(space, "Blob refusal names something that is not a hash");
return Ok(());
}
let key = Self::attachment_fetch_key(space, hash);
let answers_us = self
.pending_attachment_fetches
.get(&key)
.is_some_and(|pending| pending.is_answered_by(space, sender));
if !answers_us {
debug!(
space,
sender, "A blob refusal answers no question we put to this peer"
);
return Ok(());
}
self.pending_attachment_fetches.remove(&key);
if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
state.emit_event(Event::DataAttachmentUnavailable {
space_id: space.to_string(),
peer_id: sender.to_string(),
hash: hash.to_string(),
reason: "declined".to_string(),
});
}
Ok(())
}
pub(crate) fn accept_data_media_payload(
&mut self,
sender: &str,
purpose: &DataPurpose,
bytes: Vec<u8>,
) {
if !self.config.data.enabled {
debug!(
peer = %sender,
"Document-layer transfer dropped: the data layer is off"
);
return;
}
match purpose {
DataPurpose::Attachment { hash } => {
let key = Self::attachment_fetch_key(sender, hash);
if self.pending_attachment_fetches.remove(&key).is_none() {
warn!(
peer = %sender,
"Attachment bytes arrived for a fetch this device never made; dropping"
);
return;
}
let actual = Self::data_attachment_hash(&bytes);
if &actual != hash {
warn!(
peer = %sender,
"Attachment bytes do not hash to what was asked for; dropping"
);
if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
state.emit_event(Event::DataAttachmentUnavailable {
space_id: sender.to_string(),
peer_id: sender.to_string(),
hash: hash.clone(),
reason: "hash_mismatch".to_string(),
});
}
return;
}
if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
state.emit_event(Event::DataAttachmentReceived {
space_id: sender.to_string(),
peer_id: sender.to_string(),
hash: hash.clone(),
data: BASE64.encode(&bytes),
});
}
}
DataPurpose::Snapshot { doc } => {
if bytes.len() > MAX_MEDIA_SNAPSHOT_BYTES {
warn!(
peer = %sender,
bytes = bytes.len(),
"A carried snapshot is larger than a record can hold; refusing"
);
return;
}
let doc = doc.clone();
if let Err(err) = self.apply_contained_blob(
sender,
&SyncChannel::Peer,
&doc,
bytes,
BlobKind::Snapshot,
) {
warn!(peer = %sender, doc, error = %err, "A carried snapshot was refused");
}
}
}
}
pub(crate) fn report_data_media_failure(
&mut self,
sender: &str,
purpose: &DataPurpose,
reason: &str,
) {
match purpose {
DataPurpose::Attachment { hash } => {
let key = Self::attachment_fetch_key(sender, hash);
if self.pending_attachment_fetches.remove(&key).is_none() {
debug!(
peer = %sender,
reason,
"A transfer failed for a fetch that has already ended"
);
return;
}
if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
state.emit_event(Event::DataAttachmentUnavailable {
space_id: sender.to_string(),
peer_id: sender.to_string(),
hash: hash.clone(),
reason: reason.to_string(),
});
}
}
DataPurpose::Snapshot { doc } => {
debug!(peer = %sender, doc, reason, "A carried snapshot did not arrive");
}
}
}
fn load_sync_record(&mut self, space: &str) -> SyncRecord {
let Some(storage) = self.data_storage_for_sync() else {
return SyncRecord::default();
};
let mut record =
match self.read_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space) {
Ok(Some(bytes)) => serde_json::from_slice::<SyncRecord>(&bytes).unwrap_or_default(),
_ => SyncRecord::default(),
};
if let Some(digest) = record.in_flight.take() {
warn!(
space,
"A previous run did not survive applying a remote change; quarantining it"
);
if !record.quarantined.contains(&digest) {
record.quarantined.push(digest);
while record.quarantined.len() > MAX_QUARANTINED_BLOBS {
record.quarantined.remove(0);
}
}
self.persist_sync_record(space, &record);
}
record
}
fn persist_sync_record(&mut self, space: &str, record: &SyncRecord) {
let Some(storage) = self.data_storage_for_sync() else {
return;
};
let bytes = match serde_json::to_vec(record) {
Ok(bytes) => bytes,
Err(err) => {
warn!(space, error = %err, "Could not encode replication bookkeeping");
return;
}
};
if let Err(err) =
self.write_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space, &bytes)
{
warn!(space, error = %err, "Could not persist replication bookkeeping");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_in_both_maps_costs_two_entries_of_the_frame_budget() {
let entry = |n: usize, version: bool, floor: bool| -> OfferEntry {
(
format!("doc{n}"),
version.then(|| "AQID".to_string()),
floor.then(|| "BAUG".to_string()),
)
};
let full: Vec<OfferEntry> = (0..MAX_DOCS_PER_VERSION_FRAME)
.map(|n| entry(n, true, false))
.collect();
assert_eq!(offer_batches(&full).len(), 1);
let one_over: Vec<OfferEntry> = (0..=MAX_DOCS_PER_VERSION_FRAME)
.map(|n| entry(n, true, false))
.collect();
assert_eq!(offer_batches(&one_over).len(), 2);
let dual: Vec<OfferEntry> = (0..=MAX_DOCS_PER_VERSION_FRAME / 2)
.map(|n| entry(n, true, true))
.collect();
let batches = offer_batches(&dual);
assert_eq!(
batches.len(),
2,
"a frame of names in both maps was built to twice the byte budget"
);
assert_eq!(
batches.iter().map(|batch| batch.len()).sum::<usize>(),
dual.len(),
"an entry was dropped or duplicated across the split"
);
assert_eq!(
offer_batches(&[]).len(),
1,
"an empty offer is still one frame: it is what tells a peer to send everything"
);
}
#[test]
fn a_frame_carries_its_version_where_a_future_build_can_find_it() {
let body = SyncBody::Delta {
doc: "notes".to_string(),
blob: "AAAA".to_string(),
};
let json = serde_json::to_string(&body).unwrap();
let framed = format!("{{\"v\":{},{}", DATA_SYNC_V1, &json[1..]);
let value: serde_json::Value = serde_json::from_str(&framed).unwrap();
assert_eq!(value.get("v").and_then(|v| v.as_u64()), Some(1));
assert_eq!(value.get("k").and_then(|v| v.as_str()), Some("delta"));
let round_trip: SyncBody = serde_json::from_value(value).unwrap();
assert!(matches!(round_trip, SyncBody::Delta { doc, .. } if doc == "notes"));
}
#[test]
fn a_version_frame_round_trips() {
let body = SyncBody::Versions {
reply: true,
partial: true,
docs: BTreeMap::from([("notes".to_string(), "dgA=".to_string())]),
gone: BTreeMap::from([("old".to_string(), "ZwA=".to_string())]),
want: Some(vec!["notes".to_string(), "draft*".to_string()]),
};
let json = serde_json::to_string(&body).unwrap();
let parsed: SyncBody = serde_json::from_str(&json).unwrap();
match parsed {
SyncBody::Versions {
reply,
partial,
docs,
gone,
want,
} => {
assert!(reply);
assert!(partial);
assert_eq!(docs.get("notes").map(String::as_str), Some("dgA="));
assert_eq!(gone.get("old").map(String::as_str), Some("ZwA="));
assert_eq!(
want.as_deref(),
Some(["notes".to_string(), "draft*".to_string()].as_slice())
);
}
other => panic!("expected a version frame, got {other:?}"),
}
}
#[test]
fn an_offer_with_no_removals_carries_no_removal_field() {
let json = serde_json::to_string(&SyncBody::Versions {
reply: false,
partial: false,
docs: BTreeMap::new(),
gone: BTreeMap::new(),
want: None,
})
.unwrap();
assert_eq!(
json,
r#"{"k":"vv","reply":false,"partial":false,"docs":{}}"#
);
}
#[test]
fn an_offer_that_says_nothing_about_completeness_is_read_as_complete() {
let parsed: SyncBody = serde_json::from_str(r#"{"k":"vv","docs":{}}"#).unwrap();
match parsed {
SyncBody::Versions {
reply,
partial,
docs,
gone,
want,
} => {
assert!(!reply);
assert!(!partial);
assert!(docs.is_empty());
assert!(
want.is_none(),
"an offer from a build that predates interest read as asking for a subset"
);
assert!(
gone.is_empty(),
"an offer from a build that predates removals read as removing something"
);
}
other => panic!("expected a version frame, got {other:?}"),
}
}
#[test]
fn a_snapshot_request_round_trips_and_carries_no_bytes() {
let body = SyncBody::NeedSnapshot {
doc: "notes".to_string(),
};
let json = serde_json::to_string(&body).unwrap();
assert_eq!(json, r#"{"k":"need_snap","doc":"notes"}"#);
let parsed: SyncBody = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, SyncBody::NeedSnapshot { doc } if doc == "notes"));
}
#[test]
fn digests_separate_blobs_and_repeat_for_the_same_one() {
assert_eq!(blob_digest(b"one"), blob_digest(b"one"));
assert_ne!(blob_digest(b"one"), blob_digest(b"two"));
assert_eq!(blob_digest(b"one").len(), 32);
}
}
#[cfg(test)]
mod golden_vectors {
use super::*;
const VECTORS: &str = include_str!("../../tests/data/data-sync-v1.vectors.json");
fn vectors() -> serde_json::Value {
serde_json::from_str(VECTORS).expect("the vector file is JSON")
}
fn framed(body: &SyncBody) -> String {
let json = serde_json::to_string(body).expect("encode");
format!(
"{}{{\"v\":{},{}",
internal_prefixes::DATA_V1,
DATA_SYNC_V1,
&json[1..]
)
}
fn wire(vectors: &serde_json::Value, name: &str) -> String {
vectors["frames"]
.as_array()
.expect("frames")
.iter()
.find(|frame| frame["name"] == name)
.unwrap_or_else(|| panic!("no vector named {name}"))["wire"]
.as_str()
.expect("wire is a string")
.to_string()
}
#[test]
fn the_vector_file_is_the_size_it_was() {
let vectors = vectors();
let len = |name: &str| -> usize {
vectors[name]
.as_array()
.unwrap_or_else(|| panic!("{name} must be an array"))
.len()
};
assert_eq!(len("frames"), 12);
assert_eq!(len("parse_defaults"), 3);
assert_eq!(len("attachment_hashes"), 3);
assert_eq!(len("attachment_references"), 2);
assert_eq!(len("data_purposes"), 2);
assert_eq!(len("blob_digests"), 1);
assert_eq!(vectors["version"].as_u64(), Some(DATA_SYNC_V1 as u64));
assert_eq!(vectors["prefix"].as_str(), Some(internal_prefixes::DATA_V1));
}
#[test]
fn every_frame_kind_encodes_to_its_vector() {
let vectors = vectors();
let mut docs = BTreeMap::new();
docs.insert("notes".to_string(), "AQID".to_string());
assert_eq!(
framed(&SyncBody::Versions {
reply: false,
partial: false,
docs,
gone: BTreeMap::new(),
want: None,
}),
wire(&vectors, "version offer, complete, asking")
);
let mut docs = BTreeMap::new();
docs.insert("b".to_string(), "Zg==".to_string());
docs.insert("a".to_string(), String::new());
assert_eq!(
framed(&SyncBody::Versions {
reply: true,
partial: true,
docs,
gone: BTreeMap::new(),
want: None,
}),
wire(&vectors, "version offer, answering leg")
);
assert_eq!(
framed(&SyncBody::Versions {
reply: false,
partial: false,
docs: BTreeMap::new(),
gone: BTreeMap::new(),
want: None,
}),
wire(&vectors, "version offer with no documents")
);
let mut docs = BTreeMap::new();
docs.insert("kept".to_string(), "AQID".to_string());
let mut gone = BTreeMap::new();
gone.insert("removed".to_string(), "BAUG".to_string());
assert_eq!(
framed(&SyncBody::Versions {
reply: false,
partial: false,
docs,
gone,
want: None,
}),
wire(&vectors, "version offer carrying a removal")
);
let mut docs = BTreeMap::new();
docs.insert("notes".to_string(), "AQID".to_string());
assert_eq!(
framed(&SyncBody::Versions {
reply: false,
partial: false,
docs,
gone: BTreeMap::new(),
want: Some(vec!["notes".to_string(), "draft*".to_string()]),
}),
wire(&vectors, "version offer declaring an interest")
);
let blob = BASE64.encode([0u8, 1, 2, 3, 4, 5, 6, 7]);
assert_eq!(
framed(&SyncBody::Delta {
doc: "notes".to_string(),
blob: blob.clone(),
}),
wire(&vectors, "delta")
);
assert_eq!(
framed(&SyncBody::Snapshot {
doc: "notes".to_string(),
blob,
}),
wire(&vectors, "snapshot")
);
assert_eq!(
framed(&SyncBody::NeedSnapshot {
doc: "notes".to_string(),
}),
wire(&vectors, "snapshot request")
);
let hash = vectors["attachment_hashes"][0]["sha256"]
.as_str()
.expect("hash")
.to_string();
assert_eq!(
framed(&SyncBody::NeedBlob {
hash: hash.clone(),
to: None,
}),
wire(&vectors, "blob request")
);
assert_eq!(
framed(&SyncBody::BlobGone { hash: hash.clone() }),
wire(&vectors, "blob refusal")
);
assert_eq!(
framed(&SyncBody::NeedBlob {
hash: hash.clone(),
to: Some("off1qy60a3pu0l9tnthnk08c4w59teq7u6w28gjwg8zk".to_string()),
}),
wire(&vectors, "blob request addressed to one member")
);
assert_eq!(
framed(&SyncBody::Chunk {
hash,
i: 0,
n: 2,
blob: BASE64.encode([0u8, 1, 2, 3]),
}),
wire(&vectors, "blob chunk")
);
}
#[test]
fn every_vector_parses_back_into_the_kind_it_names() {
let vectors = vectors();
for frame in vectors["frames"].as_array().expect("frames") {
let wire = frame["wire"].as_str().expect("wire");
let body = wire
.strip_prefix(internal_prefixes::DATA_V1)
.expect("every vector carries the marker");
let value: serde_json::Value = serde_json::from_str(body).expect("the body is JSON");
assert_eq!(
value["v"].as_u64(),
Some(DATA_SYNC_V1 as u64),
"{}: version",
frame["name"]
);
assert_eq!(
value["k"].as_str(),
frame["kind"].as_str(),
"{}: kind",
frame["name"]
);
let parsed: SyncBody =
serde_json::from_value(value).expect("the body parses into a frame");
let kind = match parsed {
SyncBody::Versions { .. } => "vv",
SyncBody::Delta { .. } => "delta",
SyncBody::Snapshot { .. } => "snap",
SyncBody::NeedSnapshot { .. } => "need_snap",
SyncBody::NeedBlob { .. } => "blob",
SyncBody::BlobGone { .. } => "gone",
SyncBody::Chunk { .. } => "chunk",
};
let expected = match frame["kind"].as_str().expect("kind") {
"need_blob" => "blob",
"blob_gone" => "gone",
other => other,
};
assert_eq!(kind, expected, "{}: round trip", frame["name"]);
}
}
#[test]
fn absent_flags_parse_as_false() {
let vectors = vectors();
for case in vectors["parse_defaults"]
.as_array()
.expect("parse_defaults")
{
let value: serde_json::Value =
serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
let SyncBody::Versions { reply, partial, .. } =
serde_json::from_value(value).expect("parses")
else {
panic!("{}: expected a version offer", case["name"]);
};
assert_eq!(reply, case["expect"]["reply"].as_bool().expect("reply"));
assert_eq!(
partial,
case["expect"]["partial"].as_bool().expect("partial")
);
}
}
#[test]
fn an_absent_interest_parses_as_asking_for_everything() {
let vectors = vectors();
for case in vectors["parse_defaults"]
.as_array()
.expect("parse_defaults")
{
let value: serde_json::Value =
serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
let SyncBody::Versions { want, .. } = serde_json::from_value(value).expect("parses")
else {
panic!("{}: expected a version offer", case["name"]);
};
assert!(
case["expect"]["want"].is_null(),
"{}: this case is about an absent interest",
case["name"]
);
assert!(want.is_none(), "{}", case["name"]);
}
}
#[test]
fn an_empty_interest_is_not_an_absent_one() {
let asks_for_nothing = serde_json::to_string(&SyncBody::Versions {
reply: false,
partial: false,
docs: BTreeMap::new(),
gone: BTreeMap::new(),
want: Some(Vec::new()),
})
.unwrap();
assert!(asks_for_nothing.contains(r#""want":[]"#));
let parsed: SyncBody = serde_json::from_str(&asks_for_nothing).unwrap();
let SyncBody::Versions { want, .. } = parsed else {
panic!("expected a version offer");
};
assert_eq!(want, Some(Vec::new()));
}
#[test]
fn an_absent_removal_map_parses_as_removing_nothing() {
let vectors = vectors();
for case in vectors["parse_defaults"]
.as_array()
.expect("parse_defaults")
{
let value: serde_json::Value =
serde_json::from_str(case["body"].as_str().expect("body")).expect("JSON");
let SyncBody::Versions { gone, .. } = serde_json::from_value(value).expect("parses")
else {
panic!("{}: expected a version offer", case["name"]);
};
let expected = case["expect"]["gone"].as_object().expect("gone");
assert_eq!(gone.len(), expected.len(), "{}", case["name"]);
}
}
#[test]
fn attachment_hashes_match_their_vectors() {
let vectors = vectors();
for case in vectors["attachment_hashes"]
.as_array()
.expect("attachment_hashes")
{
let bytes = match (case.get("input_utf8"), case.get("input_hex")) {
(Some(text), _) => text.as_str().expect("utf8").as_bytes().to_vec(),
(_, Some(hex)) => {
let hex = hex.as_str().expect("hex");
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
.collect()
}
_ => panic!("a hash vector needs an input"),
};
assert_eq!(
bytes.len() as u64,
case["size"].as_u64().expect("size"),
"the vector's own size must describe its own input"
);
assert_eq!(
OfflineProtocol::data_attachment_hash(&bytes),
case["sha256"].as_str().expect("sha256").to_string()
);
}
}
#[test]
fn attachment_references_encode_to_their_vectors() {
use offline_protocol_data::DataValue;
let vectors = vectors();
let hash = vectors["attachment_hashes"][0]["sha256"]
.as_str()
.expect("hash")
.to_string();
let size = vectors["attachment_hashes"][0]["size"]
.as_u64()
.expect("size");
let full = DataValue::Attachment {
hash: hash.clone(),
size,
name: Some("plan.pdf".to_string()),
mime: Some("application/pdf".to_string()),
};
assert_eq!(
serde_json::to_string(&full).expect("encode"),
vectors["attachment_references"][0]["json"]
.as_str()
.expect("json")
);
let bare = DataValue::attachment(hash, size);
assert_eq!(
serde_json::to_string(&bare).expect("encode"),
vectors["attachment_references"][1]["json"]
.as_str()
.expect("json")
);
assert!(!vectors["attachment_references"][1]["json"]
.as_str()
.expect("json")
.contains("null"));
}
#[test]
fn data_purposes_encode_to_their_vectors() {
let vectors = vectors();
let hash = vectors["attachment_hashes"][0]["sha256"]
.as_str()
.expect("hash")
.to_string();
assert_eq!(
serde_json::to_string(&DataPurpose::Attachment { hash }).expect("encode"),
vectors["data_purposes"][0]["json"].as_str().expect("json")
);
assert_eq!(
serde_json::to_string(&DataPurpose::Snapshot {
doc: "notes".to_string(),
})
.expect("encode"),
vectors["data_purposes"][1]["json"].as_str().expect("json")
);
}
#[test]
fn blob_digests_match_their_vectors() {
let vectors = vectors();
for case in vectors["blob_digests"].as_array().expect("blob_digests") {
let hex = case["input_hex"].as_str().expect("hex");
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex byte"))
.collect();
assert_eq!(
blob_digest(&bytes),
case["digest"].as_str().expect("digest")
);
}
}
}
impl SyncBody {
pub(crate) fn custody_class(&self) -> Option<&'static str> {
match self {
SyncBody::Versions { .. }
| SyncBody::Delta { .. }
| SyncBody::Snapshot { .. }
| SyncBody::BlobGone { .. } => Some(crate::protocol::custody::CUSTODY_CLASS_DATA),
SyncBody::NeedSnapshot { .. } | SyncBody::NeedBlob { .. } | SyncBody::Chunk { .. } => {
None
}
}
}
}
pub(crate) fn custody_class_of_plaintext(plaintext: &str) -> Option<&'static str> {
let body = plaintext.strip_prefix(internal_prefixes::DATA_V1)?;
let value: serde_json::Value = serde_json::from_str(body).ok()?;
if value.get("v").and_then(serde_json::Value::as_u64) != Some(u64::from(DATA_SYNC_V1)) {
return None;
}
serde_json::from_value::<SyncBody>(value)
.ok()?
.custody_class()
}
#[cfg(test)]
mod custody_class_tests {
use super::*;
fn framed(body: &str) -> String {
format!(
"{}{{\"v\":{},{}",
internal_prefixes::DATA_V1,
DATA_SYNC_V1,
&body[1..]
)
}
#[test]
fn class_a_kinds_are_deposited_and_the_rest_are_not() {
for (kind, expected) in [
(
"{\"k\":\"delta\",\"doc\":\"d\",\"blob\":\"AA==\"}",
Some("data"),
),
(
"{\"k\":\"snap\",\"doc\":\"d\",\"blob\":\"AA==\"}",
Some("data"),
),
("{\"k\":\"vv\",\"docs\":{}}", Some("data")),
("{\"k\":\"blob_gone\",\"hash\":\"h\"}", Some("data")),
("{\"k\":\"need_snap\",\"doc\":\"d\"}", None),
("{\"k\":\"need_blob\",\"hash\":\"h\"}", None),
(
"{\"k\":\"chunk\",\"hash\":\"h\",\"i\":0,\"n\":1,\"blob\":\"AA==\"}",
None,
),
] {
assert_eq!(
custody_class_of_plaintext(&framed(kind)),
expected,
"{kind}"
);
}
}
#[test]
fn anything_but_a_readable_sync_frame_is_not_deposited() {
assert_eq!(custody_class_of_plaintext("hello"), None);
assert_eq!(
custody_class_of_plaintext(&format!("{}not json", internal_prefixes::DATA_V1)),
None
);
let future = format!(
"{}{{\"v\":{},\"k\":\"delta\",\"doc\":\"d\",\"blob\":\"AA==\"}}",
internal_prefixes::DATA_V1,
DATA_SYNC_V1 + 1
);
assert_eq!(custody_class_of_plaintext(&future), None);
}
}