use std::collections::BTreeMap;
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 MAX_DOCS_PER_SPACE: usize = 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(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(rename = "blob_gone")]
BlobGone { hash: String },
}
#[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;
}
};
debug!(space = %peer, cause, docs = docs.len(), "Offering document versions");
self.send_version_frames(peer, channel, docs, false, false);
}
fn send_version_frames(
&mut self,
space: &str,
channel: &SyncChannel,
docs: BTreeMap<String, String>,
reply: bool,
force_partial: bool,
) {
if docs.is_empty() && reply {
return;
}
let batches: Vec<BTreeMap<String, String>> = if docs.is_empty() {
vec![BTreeMap::new()]
} else {
docs.into_iter()
.collect::<Vec<_>>()
.chunks(MAX_DOCS_PER_VERSION_FRAME)
.map(|chunk| chunk.iter().cloned().collect())
.collect()
};
let partial = force_partial || batches.len() > 1;
for batch in batches {
self.send_sync_frame(
space,
channel,
&SyncBody::Versions {
reply,
partial,
docs: batch,
},
);
}
}
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,
BTreeMap::from([(doc.to_string(), version)]),
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());
self.stage_outbox_reseal(
&message.id,
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,
},
);
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 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),
},
);
}
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,
} => self.answer_version_offer(&space, channel, reply, partial, docs),
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 } => self.answer_blob_request(&space, channel, &hash),
SyncBody::BlobGone { hash } => self.report_blob_gone(channel, &space, &hash),
};
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,
theirs: BTreeMap<String, String>,
) -> Result<()> {
let mut ours = self.data_sync_versions(space).unwrap_or_default();
let mut created = BTreeMap::new();
for doc in theirs.keys() {
if ours.contains_key(doc) || offline_protocol_data::validate_name(doc).is_err() {
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 {
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 {
self.send_version_frames(space, channel, ours, true, false);
} else {
self.send_version_frames(space, channel, created, true, true);
}
Ok(())
}
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_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(());
}
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; attachment carriage is 1:1 in this version"
)));
}
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(asked_at) = self.pending_attachment_fetches.get(&key) {
if Instant::now().duration_since(*asked_at) < 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(|(_, seen_at)| **seen_at)
.map(|(key, _)| key.clone())
{
self.pending_attachment_fetches.remove(&oldest);
Self::report_fetch_ended(&self.shared_state, &oldest, "evicted");
}
}
self.pending_attachment_fetches.insert(key, Instant::now());
self.send_sync_frame(
space,
&SyncChannel::Peer,
&SyncBody::NeedBlob {
hash: hash.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(),
));
}
if space != peer {
return Err(Error::InvalidArgument(format!(
"space {space} is not the 1:1 space with {peer}"
)));
}
let actual = Self::data_attachment_hash(&bytes);
if actual != hash {
return Err(Error::InvalidArgument(format!(
"these bytes hash to {actual}, not to {hash}"
)));
}
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)?;
if space != peer {
return Err(Error::InvalidArgument(format!(
"space {space} is not the 1:1 space with {peer}"
)));
}
if !self.data_media_active(peer) {
return Err(Error::InvalidArgument(format!(
"peer {peer} did not advertise attachment carriage"
)));
}
self.send_sync_frame(
space,
&SyncChannel::Peer,
&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> = self
.pending_attachment_fetches
.keys()
.filter(|key| key.starts_with(&prefix))
.cloned()
.collect();
for key in ended {
self.pending_attachment_fetches.remove(&key);
Self::report_fetch_ended(&self.shared_state, &key, "peer_gone");
}
self.blob_request_windows
.retain(|key, _| !key.starts_with(&prefix));
}
pub(crate) fn end_every_pending_fetch(&mut self) {
for key in std::mem::take(&mut self.pending_attachment_fetches).into_keys() {
Self::report_fetch_ended(&self.shared_state, &key, "peer_gone");
}
}
fn report_fetch_ended(
shared_state: &std::sync::Arc<std::sync::Mutex<crate::protocol::SharedState>>,
key: &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: 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 = 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> = self
.pending_attachment_fetches
.iter()
.filter(|(_, asked_at)| now.duration_since(**asked_at) >= ATTACHMENT_FETCH_TIMEOUT)
.map(|(key, _)| key.clone())
.collect();
for key in expired {
self.pending_attachment_fetches.remove(&key);
Self::report_fetch_ended(&self.shared_state, &key, "timeout");
}
}
fn answer_blob_request(
&mut self,
space: &str,
channel: &SyncChannel,
hash: &str,
) -> Result<()> {
if !matches!(channel, SyncChannel::Peer) {
warn!(
space,
"A blob request arrived inside a group; attachment carriage is 1:1"
);
return Ok(());
}
if Self::validate_attachment_hash(hash).is_err() {
warn!(space, "Blob request names something that is not a hash");
return Ok(());
}
if !self.data_media_active(space) {
warn!(
space,
"A blob request came from a peer that never advertised carriage; ignoring"
);
return Ok(());
}
if !self.blob_request_due(space, 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: space.to_string(),
hash: hash.to_string(),
});
}
Ok(())
}
fn report_blob_gone(&mut self, channel: &SyncChannel, space: &str, hash: &str) -> Result<()> {
if !matches!(channel, SyncChannel::Peer) {
warn!(
space,
"A blob refusal arrived inside a group; attachment carriage is 1:1"
);
return Ok(());
}
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);
if self.pending_attachment_fetches.remove(&key).is_none() {
debug!(space, "A blob refusal answers no question we asked");
return Ok(());
}
if let Ok(state) = crate::protocol::lock_shared_state(&self.shared_state) {
state.emit_event(Event::DataAttachmentUnavailable {
space_id: space.to_string(),
peer_id: space.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_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())]),
};
let json = serde_json::to_string(&body).unwrap();
let parsed: SyncBody = serde_json::from_str(&json).unwrap();
match parsed {
SyncBody::Versions {
reply,
partial,
docs,
} => {
assert!(reply);
assert!(partial);
assert_eq!(docs.get("notes").map(String::as_str), Some("dgA="));
}
other => panic!("expected a version frame, got {other:?}"),
}
}
#[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,
} => {
assert!(!reply);
assert!(!partial);
assert!(docs.is_empty());
}
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"), 8);
assert_eq!(len("parse_defaults"), 1);
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,
}),
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,
}),
wire(&vectors, "version offer, answering leg")
);
assert_eq!(
framed(&SyncBody::Versions {
reply: false,
partial: false,
docs: BTreeMap::new(),
}),
wire(&vectors, "version offer with no documents")
);
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() }),
wire(&vectors, "blob request")
);
assert_eq!(
framed(&SyncBody::BlobGone { hash }),
wire(&vectors, "blob refusal")
);
}
#[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",
};
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 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")
);
}
}
}