summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-26 04:00:59 +0100
committertv <tv@krebsco.de>2026-10-26 04:00:59 +0100
commitc13cee08dedeb8e73560f9bf9f7154c9fa089e2d (patch)
tree368dba4e3513556c9f33d8531383900051243ae3 /src
parent0039ffe04d290935936d33e681c2a0f2575c4264 (diff)
replace shadow types by wire codec
Diffstat (limited to 'src')
-rw-r--r--src/transport/mod.rs38
-rw-r--r--src/transport/mycelium.rs469
-rw-r--r--src/transport/wire.rs354
3 files changed, 433 insertions, 428 deletions
diff --git a/src/transport/mod.rs b/src/transport/mod.rs
index 35e3499..14c5ee4 100644
--- a/src/transport/mod.rs
+++ b/src/transport/mod.rs
@@ -2,6 +2,7 @@ use anyhow::Result;
use async_trait::async_trait;
use automerge::ChangeHash;
use automerge::sync::Message;
+use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::boxed::Box;
use std::future::Future;
@@ -14,6 +15,7 @@ pub mod mycelium;
#[cfg(test)]
pub(crate) mod sim;
pub mod traffic;
+pub(crate) mod wire;
/// Random identifier of one run of a node (from start to exit).
pub type RunId = [u8; 16];
@@ -83,10 +85,12 @@ fn digest_of(sorted_heads: &[ChangeHash]) -> [u8; 32] {
}
/// PING and PONG. Addressed to one peer: `syncs_with_you` is about the recipient.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Keepalive {
/// Only comparable between nodes on the same epoch.
+ #[serde(with = "crate::transport::wire::codec::heads")]
pub heads: HeadsSummary,
+ #[serde(with = "crate::transport::wire::codec::epoch")]
pub epoch: Epoch,
/// See `SyncEnvelope::epoch_seq`.
pub epoch_seq: u32,
@@ -109,12 +113,12 @@ pub enum ParsedMessage {
/// Content-addressed bytes that any node holding them can serve, in chunks
/// (`swarm::blob`).
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlobId {
/// An Automerge change, by its hash: e.g. an epoch's snapshot change.
- Change(ChangeHash),
+ Change(#[serde(with = "crate::transport::wire::codec::change_hash")] ChangeHash),
/// Bytes by their BLAKE3 hash: e.g. the content of a remake.
- Content([u8; 32]),
+ Content(#[serde(with = "crate::transport::wire::codec::bytes32")] [u8; 32]),
}
impl std::fmt::Display for BlobId {
@@ -131,9 +135,10 @@ impl std::fmt::Display for BlobId {
/// A blob request or one of its answers. Each is a message of its own, so no message
/// is larger than one chunk.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobMessage {
/// The sender's epoch (every message carries it).
+ #[serde(with = "crate::transport::wire::codec::epoch")]
pub epoch: Epoch,
/// See `SyncEnvelope::epoch_seq`.
pub epoch_seq: u32,
@@ -141,20 +146,20 @@ pub struct BlobMessage {
pub body: BlobBody,
}
-#[derive(Clone)]
+#[derive(Clone, Serialize, Deserialize)]
pub enum BlobBody {
/// Asks for these chunks; for the manifest if empty.
- Request {
- chunks: Vec<u32>,
- },
+ Request { chunks: Vec<u32> },
/// Size and per-chunk BLAKE3 hashes; the last chunk may be shorter.
Manifest {
size: u64,
chunk_size: u32,
+ #[serde(with = "crate::transport::wire::codec::hashes32")]
hashes: Vec<[u8; 32]>,
},
Chunk {
index: u32,
+ #[serde(with = "crate::transport::wire::codec::shared_bytes")]
data: bytes::Bytes,
},
/// The sender doesn't have the blob.
@@ -234,52 +239,61 @@ impl ParsedMessage {
}
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloRequest {
/// The sender's swarm, if it knows it (a new node doesn't yet). A receiver in
/// another swarm doesn't answer.
pub swarm_id: Option<String>,
+ #[serde(with = "crate::transport::wire::codec::epoch")]
pub epoch: Epoch,
/// See `SyncEnvelope::epoch_seq`.
pub epoch_seq: u32,
/// Proof of the swarm key (`swarm::auth`); empty without one.
+ #[serde(with = "serde_bytes")]
pub auth: Vec<u8>,
/// The sender is ready (same meaning as `SyncEnvelope::ready`).
pub ready: bool,
/// Random per run of the sender; the reply echoes it.
+ #[serde(with = "crate::transport::wire::codec::run_id")]
pub run_id: RunId,
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloReply {
pub swarm_id: String,
pub peers: Vec<String>,
+ #[serde(with = "crate::transport::wire::codec::epoch")]
pub epoch: Epoch,
/// See `SyncEnvelope::epoch_seq`.
pub epoch_seq: u32,
/// Proof of the swarm key (`swarm::auth`); empty without one.
+ #[serde(with = "serde_bytes")]
pub auth: Vec<u8>,
/// The sender is ready.
pub ready: bool,
/// The `run_id` of the HELLO answered. A reply to a HELLO from an earlier run of the
/// recipient (delivered late, or queued while it was down) is ignored.
+ #[serde(with = "crate::transport::wire::codec::run_id")]
pub run_id: RunId,
/// How long before this HELLO the sender had last heard from the recipient, if ever
/// (in milliseconds): tells a restarted node how long it was away.
pub away_ms: Option<u64>,
/// The sender's own run (as its HELLOs carry it): a new one means it restarted.
+ #[serde(with = "crate::transport::wire::codec::run_id")]
pub sender_run_id: RunId,
}
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncEnvelope {
pub swarm_id: String,
/// Epoch of the document the message was generated from.
+ #[serde(with = "crate::transport::wire::codec::epoch")]
pub epoch: Epoch,
/// How many times the sender changed epoch in its current run, when it sent this
/// (`Replica::epoch_seq`). Messages may arrive late and out of order: the receiver
/// ignores an epoch older than the newest it has seen in the same session.
pub epoch_seq: u32,
+ #[serde(with = "crate::transport::wire::codec::sync_message")]
pub message: Message,
/// The sender was ready when it generated the message: its document is the swarm's,
/// not one it is still filling (joining, rejoining). Only such a peer can tell a
diff --git a/src/transport/mycelium.rs b/src/transport/mycelium.rs
index efab475..a7bd7d9 100644
--- a/src/transport/mycelium.rs
+++ b/src/transport/mycelium.rs
@@ -1,11 +1,8 @@
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
-use automerge::ChangeHash;
use base64::engine::{Engine, general_purpose::STANDARD as BASE64};
use reqwest::StatusCode;
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-use serde_bytes::{ByteBuf, Bytes};
-use std::borrow::Cow;
+use serde::{Deserialize, Serialize};
use std::boxed::Box;
use std::fs::File;
use std::io::Write;
@@ -18,21 +15,14 @@ use thiserror::Error;
use tokio::process::{Child, Command};
use url::Url;
-use crate::transport::EncodedMessage;
-use crate::transport::{BlobBody, BlobId, BlobMessage};
-use crate::transport::{Epoch, HelloReply, HelloRequest, SyncEnvelope};
-use crate::transport::{HeadsSummary, Keepalive, MAX_LISTED_HEADS};
-use crate::transport::{MessageHandler, ParsedMessage, Transport};
+use crate::transport::wire;
+use crate::transport::{EncodedMessage, MessageHandler, ParsedMessage, Transport};
const MYCELIUM_POLL_TIMEOUT_SECS: u64 = 300;
-const TOPIC_PREFIX: &str = "continuum.v1.";
-const TOPIC_HELLO: &str = "continuum.v1.hello";
-const TOPIC_HELLO_REPLY: &str = "continuum.v1.hello-reply";
-const TOPIC_SYNC: &str = "continuum.v1.sync";
-const TOPIC_PING: &str = "continuum.v1.ping";
-const TOPIC_PONG: &str = "continuum.v1.pong";
-const TOPIC_BLOB: &str = "continuum.v1.blob";
+/// The one Mycelium topic all of continuum's messages travel on; what a message is, its
+/// payload says (`wire`).
+const TOPIC: &str = "continuum.v1";
const EMPTY_QUEUE_DELAY: Duration = Duration::from_millis(25);
@@ -490,7 +480,7 @@ impl MyceliumTransport {
.client
.get(self.messages_endpoint.clone())
.query(&[("timeout", 0)])
- .query(&[("topic", encode_topic(TOPIC_PREFIX))])
+ .query(&[("topic", encode_topic(TOPIC))])
.send()
.await;
match response {
@@ -626,7 +616,7 @@ impl Transport for MyceliumTransport {
.poll_client
.get(self.messages_endpoint.clone())
.query(&[("timeout", MYCELIUM_POLL_TIMEOUT_SECS)])
- .query(&[("topic", encode_topic(TOPIC_PREFIX))])
+ .query(&[("topic", encode_topic(TOPIC))])
.send()
.await
{
@@ -746,26 +736,11 @@ fn base64_decoded_len(encoded: &str) -> usize {
}
fn decode_payload(topic_encoded: &str, payload: &str) -> Result<ParsedMessage, TransportError> {
- let topic: &str = &decode_topic(topic_encoded)?;
- let bytes = BASE64.decode(payload)?;
- match topic {
- TOPIC_HELLO => {
- let msg = bytes_to_hello_request(&bytes)?;
- Ok(ParsedMessage::Hello(msg))
- }
- TOPIC_HELLO_REPLY => {
- let msg = bytes_to_hello_reply(&bytes)?;
- Ok(ParsedMessage::HelloReply(msg))
- }
- TOPIC_SYNC => {
- let msg = bytes_to_sync_envelope(&bytes)?;
- Ok(ParsedMessage::Sync(msg))
- }
- TOPIC_PING => Ok(ParsedMessage::Ping(bytes_to_keepalive(&bytes)?)),
- TOPIC_PONG => Ok(ParsedMessage::Pong(bytes_to_keepalive(&bytes)?)),
- TOPIC_BLOB => Ok(ParsedMessage::Blob(bytes_to_blob(&bytes)?)),
- t => Err(TransportError::UnknownTopic(t.to_string())),
+ let topic = decode_topic(topic_encoded)?;
+ if topic != TOPIC {
+ return Err(TransportError::UnknownTopic(topic));
}
+ Ok(wire::decode(&BASE64.decode(payload)?)?)
}
fn parse_message(message: &InboundMessage) -> Result<(String, ParsedMessage), TransportError> {
@@ -785,376 +760,17 @@ fn format_message(
recipient: &str,
message: &ParsedMessage,
) -> Result<(OutboundMessage, usize), TransportError> {
- let (topic, payload_bytes) = match message {
- ParsedMessage::Hello(req) => (TOPIC_HELLO, hello_request_to_bytes(req)?),
- ParsedMessage::HelloReply(res) => (TOPIC_HELLO_REPLY, hello_reply_to_bytes(res)?),
- ParsedMessage::Sync(env) => (TOPIC_SYNC, sync_envelope_to_bytes(env)?),
- ParsedMessage::Ping(k) => (TOPIC_PING, keepalive_to_bytes(k)?),
- ParsedMessage::Pong(k) => (TOPIC_PONG, keepalive_to_bytes(k)?),
- ParsedMessage::Blob(blob) => (TOPIC_BLOB, blob_to_bytes(blob)?),
- };
+ let payload_bytes = wire::encode(message)?;
let outbound = OutboundMessage {
dst: Destination {
pk: recipient.to_owned(),
},
- topic: encode_topic(topic),
+ topic: encode_topic(TOPIC),
payload: encode_payload(&payload_bytes),
};
Ok((outbound, payload_bytes.len()))
}
-// =====================================================================
-// 1. WIRE SHADOWS
-// =====================================================================
-//
-// rmp_serde encodes structs as arrays. Fields added later are appended at the end and
-// marked #[serde(default)], so a message from a node that predates them still decodes
-// (an absent epoch is the genesis epoch).
-
-/// Epoch on the wire. The default (generation 0, empty id) is the genesis epoch.
-#[derive(Serialize, Deserialize, Default)]
-struct EpochShadow {
- generation: u64,
- id: ByteBuf,
- /// The sender's epoch changes in its current run (`SyncEnvelope::epoch_seq`).
- #[serde(default)]
- seq: u32,
-}
-
-fn epoch_to_shadow(epoch: &Epoch, seq: u32) -> EpochShadow {
- EpochShadow {
- generation: epoch.generation,
- id: ByteBuf::from(epoch.id.to_vec()),
- seq,
- }
-}
-
-fn epoch_from_shadow(shadow: &EpochShadow) -> Result<Epoch, TransportError> {
- if shadow.generation == 0 && shadow.id.is_empty() {
- return Ok(Epoch::GENESIS);
- }
- let id = shadow
- .id
- .as_slice()
- .try_into()
- .map_err(|_| TransportError::InvalidPayload("epoch id must be 32 bytes".into()))?;
- Ok(Epoch {
- generation: shadow.generation,
- id,
- })
-}
-
-/// Either `heads` (listed) or `digest` (32 bytes, `heads` empty) is set.
-#[derive(Serialize, Deserialize)]
-struct KeepaliveShadow {
- heads: Vec<ByteBuf>,
- digest: ByteBuf,
- epoch: EpochShadow,
- ready: bool,
- syncs_with_you: bool,
-}
-
-fn keepalive_to_bytes(msg: &Keepalive) -> Result<Vec<u8>, rmp_serde::encode::Error> {
- let (heads, digest) = match &msg.heads {
- HeadsSummary::Heads(heads) => (
- heads
- .iter()
- .map(|head| ByteBuf::from(head.0.to_vec()))
- .collect(),
- ByteBuf::new(),
- ),
- HeadsSummary::Digest(digest) => (Vec::new(), ByteBuf::from(digest.to_vec())),
- };
- rmp_serde::to_vec(&KeepaliveShadow {
- heads,
- digest,
- epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
- ready: msg.ready,
- syncs_with_you: msg.syncs_with_you,
- })
-}
-
-fn bytes_to_keepalive(bytes: &[u8]) -> Result<Keepalive, TransportError> {
- let shadow: KeepaliveShadow = rmp_serde::from_slice(bytes)?;
- let heads = if shadow.digest.is_empty() {
- if shadow.heads.len() > MAX_LISTED_HEADS {
- return Err(TransportError::InvalidPayload(format!(
- "more than {MAX_LISTED_HEADS} heads listed"
- )));
- }
- let heads = shadow
- .heads
- .iter()
- .map(|head| ChangeHash::try_from(head.as_slice()))
- .collect::<Result<Vec<_>, _>>()
- .map_err(|_| TransportError::InvalidPayload("a head must be 32 bytes".into()))?;
- HeadsSummary::Heads(heads)
- } else {
- let digest =
- shadow.digest.as_slice().try_into().map_err(|_| {
- TransportError::InvalidPayload("heads digest must be 32 bytes".into())
- })?;
- HeadsSummary::Digest(digest)
- };
- Ok(Keepalive {
- heads,
- epoch: epoch_from_shadow(&shadow.epoch)?,
- epoch_seq: shadow.epoch.seq,
- ready: shadow.ready,
- syncs_with_you: shadow.syncs_with_you,
- })
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct HelloRequestShadow {
- swarm_id: Option<String>,
- epoch: EpochShadow,
- auth: ByteBuf,
- ready: bool,
- run_id: ByteBuf,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct HelloReplyShadow<'a> {
- // Cow allows zero-copy references when saving, but creates an owned String when loading
- pub swarm_id: Cow<'a, str>,
- #[serde(default)]
- pub peers: Vec<String>,
- #[serde(default)]
- epoch: EpochShadow,
- #[serde(default)]
- auth: ByteBuf,
- ready: bool,
- run_id: ByteBuf,
- away_ms: Option<u64>,
- sender_run_id: ByteBuf,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct SyncEnvelopeShadow<'a> {
- pub swarm_id: Cow<'a, str>,
- #[serde(with = "automerge_msg_codec")]
- pub message: automerge::sync::Message,
- #[serde(default)]
- epoch: EpochShadow,
- /// Absent from nodes that predate it: never counts as ready.
- #[serde(default)]
- ready: bool,
-}
-
-// =====================================================================
-// 2. CUSTOM CODEC
-// =====================================================================
-mod automerge_msg_codec {
- use super::*;
- use automerge::sync::Message;
-
- pub fn serialize<S>(msg: &Message, serializer: S) -> Result<S::Ok, S::Error>
- where
- S: Serializer,
- {
- let bytes = msg.clone().encode();
- Bytes::new(&bytes).serialize(serializer)
- }
-
- pub fn deserialize<'de, D>(deserializer: D) -> Result<Message, D::Error>
- where
- D: Deserializer<'de>,
- {
- let byte_buf = ByteBuf::deserialize(deserializer)?;
- Message::decode(&byte_buf)
- .map_err(|e| serde::de::Error::custom(format!("Automerge decode failed: {:?}", e)))
- }
-}
-
-// =====================================================================
-// 3. CLEAN CONVERSION INTERFACE
-// =====================================================================
-
-// --- HelloRequest ---
-pub fn hello_request_to_bytes(msg: &HelloRequest) -> Result<Vec<u8>, rmp_serde::encode::Error> {
- rmp_serde::to_vec(&HelloRequestShadow {
- swarm_id: msg.swarm_id.clone(),
- epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
- auth: ByteBuf::from(msg.auth.clone()),
- ready: msg.ready,
- run_id: ByteBuf::from(msg.run_id.to_vec()),
- })
-}
-pub fn bytes_to_hello_request(bytes: &[u8]) -> Result<HelloRequest, TransportError> {
- let shadow: HelloRequestShadow = rmp_serde::from_slice(bytes)?;
- Ok(HelloRequest {
- swarm_id: shadow.swarm_id,
- epoch: epoch_from_shadow(&shadow.epoch)?,
- epoch_seq: shadow.epoch.seq,
- auth: shadow.auth.into_vec(),
- ready: shadow.ready,
- run_id: run_id_from_shadow(&shadow.run_id)?,
- })
-}
-
-fn run_id_from_shadow(bytes: &ByteBuf) -> Result<crate::transport::RunId, TransportError> {
- bytes
- .as_slice()
- .try_into()
- .map_err(|_| TransportError::InvalidPayload("run id must be 16 bytes".into()))
-}
-
-// --- HelloReply ---
-pub fn hello_reply_to_bytes(msg: &HelloReply) -> Result<Vec<u8>, rmp_serde::encode::Error> {
- let shadow = HelloReplyShadow {
- swarm_id: Cow::Borrowed(&msg.swarm_id),
- peers: msg.peers.clone(), // Vec must be cloned for serialization wrapper matching
- epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
- auth: ByteBuf::from(msg.auth.clone()),
- ready: msg.ready,
- run_id: ByteBuf::from(msg.run_id.to_vec()),
- away_ms: msg.away_ms,
- sender_run_id: ByteBuf::from(msg.sender_run_id.to_vec()),
- };
- rmp_serde::to_vec(&shadow)
-}
-pub fn bytes_to_hello_reply(bytes: &[u8]) -> Result<HelloReply, TransportError> {
- let shadow: HelloReplyShadow = rmp_serde::from_slice(bytes)?;
- Ok(HelloReply {
- swarm_id: shadow.swarm_id.into_owned(),
- peers: shadow.peers,
- epoch: epoch_from_shadow(&shadow.epoch)?,
- epoch_seq: shadow.epoch.seq,
- auth: shadow.auth.into_vec(),
- ready: shadow.ready,
- run_id: run_id_from_shadow(&shadow.run_id)?,
- away_ms: shadow.away_ms,
- sender_run_id: run_id_from_shadow(&shadow.sender_run_id)?,
- })
-}
-
-// --- SyncEnvelope ---
-pub fn sync_envelope_to_bytes(msg: &SyncEnvelope) -> Result<Vec<u8>, rmp_serde::encode::Error> {
- let shadow = SyncEnvelopeShadow {
- swarm_id: Cow::Borrowed(&msg.swarm_id),
- message: msg.message.clone(), // Clone required by automerge_msg_codec ownership rules
- epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
- ready: msg.ready,
- };
- rmp_serde::to_vec(&shadow)
-}
-pub fn bytes_to_sync_envelope(bytes: &[u8]) -> Result<SyncEnvelope, TransportError> {
- let shadow: SyncEnvelopeShadow = rmp_serde::from_slice(bytes)?;
- Ok(SyncEnvelope {
- swarm_id: shadow.swarm_id.into_owned(),
- epoch: epoch_from_shadow(&shadow.epoch)?,
- epoch_seq: shadow.epoch.seq,
- message: shadow.message,
- ready: shadow.ready,
- })
-}
-
-// --- BlobMessage ---
-#[derive(Serialize, Deserialize)]
-enum BlobIdShadow {
- Change(ByteBuf),
- Content(ByteBuf),
-}
-
-#[derive(Serialize, Deserialize)]
-enum BlobBodyShadow {
- Request {
- chunks: Vec<u32>,
- },
- Manifest {
- size: u64,
- chunk_size: u32,
- hashes: Vec<ByteBuf>,
- },
- Chunk {
- index: u32,
- data: ByteBuf,
- },
- Missing,
-}
-
-#[derive(Serialize, Deserialize)]
-struct BlobShadow {
- epoch: EpochShadow,
- id: BlobIdShadow,
- body: BlobBodyShadow,
-}
-
-fn blob_to_bytes(msg: &BlobMessage) -> Result<Vec<u8>, rmp_serde::encode::Error> {
- let id = match msg.id {
- BlobId::Change(hash) => BlobIdShadow::Change(ByteBuf::from(hash.0.to_vec())),
- BlobId::Content(hash) => BlobIdShadow::Content(ByteBuf::from(hash.to_vec())),
- };
- let body = match &msg.body {
- BlobBody::Request { chunks } => BlobBodyShadow::Request {
- chunks: chunks.clone(),
- },
- BlobBody::Manifest {
- size,
- chunk_size,
- hashes,
- } => BlobBodyShadow::Manifest {
- size: *size,
- chunk_size: *chunk_size,
- hashes: hashes
- .iter()
- .map(|hash| ByteBuf::from(hash.to_vec()))
- .collect(),
- },
- BlobBody::Chunk { index, data } => BlobBodyShadow::Chunk {
- index: *index,
- data: ByteBuf::from(data.to_vec()),
- },
- BlobBody::Missing => BlobBodyShadow::Missing,
- };
- rmp_serde::to_vec(&BlobShadow {
- epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
- id,
- body,
- })
-}
-
-fn hash32(bytes: &[u8], what: &str) -> Result<[u8; 32], TransportError> {
- bytes
- .try_into()
- .map_err(|_| TransportError::InvalidPayload(format!("{what} must be 32 bytes")))
-}
-
-fn bytes_to_blob(bytes: &[u8]) -> Result<BlobMessage, TransportError> {
- let shadow: BlobShadow = rmp_serde::from_slice(bytes)?;
- let id = match shadow.id {
- BlobIdShadow::Change(hash) => BlobId::Change(ChangeHash(hash32(&hash, "a change hash")?)),
- BlobIdShadow::Content(hash) => BlobId::Content(hash32(&hash, "a content hash")?),
- };
- let body = match shadow.body {
- BlobBodyShadow::Request { chunks } => BlobBody::Request { chunks },
- BlobBodyShadow::Manifest {
- size,
- chunk_size,
- hashes,
- } => BlobBody::Manifest {
- size,
- chunk_size,
- hashes: hashes
- .iter()
- .map(|hash| hash32(hash, "a chunk hash"))
- .collect::<Result<_, _>>()?,
- },
- BlobBodyShadow::Chunk { index, data } => BlobBody::Chunk {
- index,
- data: data.into_vec().into(),
- },
- BlobBodyShadow::Missing => BlobBody::Missing,
- };
- Ok(BlobMessage {
- epoch: epoch_from_shadow(&shadow.epoch)?,
- epoch_seq: shadow.epoch.seq,
- id,
- body,
- })
-}
-
#[derive(Error, Debug)]
pub enum TransportError {
#[error("Mycelium delivered a message without a sender public key")]
@@ -1166,18 +782,12 @@ pub enum TransportError {
#[error("UTF-8 decoding failed: {0}")]
Utf8Decode(#[from] std::string::FromUtf8Error),
- #[error("MessagePack serialization failed: {0}")]
- MessagePackEncode(#[from] rmp_serde::encode::Error),
-
- #[error("MessagePack deserialization failed: {0}")]
- MessagePackDecode(#[from] rmp_serde::decode::Error),
+ #[error(transparent)]
+ Wire(#[from] wire::WireError),
#[error("Unknown topic: {0}")]
UnknownTopic(String),
- #[error("Invalid payload: {0}")]
- InvalidPayload(String),
-
#[error("Network HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
@@ -1191,6 +801,10 @@ pub enum TransportError {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::transport::{
+ BlobBody, BlobId, BlobMessage, Epoch, HeadsSummary, Keepalive, MAX_LISTED_HEADS,
+ };
+ use automerge::ChangeHash;
/// Mycelium splits messages into chunks of this size.
const MYCELIUM_CHUNK: usize = 1300;
@@ -1211,14 +825,17 @@ mod tests {
ready: true,
syncs_with_you: true,
};
- let payload = keepalive_to_bytes(&keepalive).unwrap();
+ let payload = wire::encode(&ParsedMessage::Pong(keepalive.clone())).unwrap();
// Mycelium's message data: a length byte, the topic, then our payload.
- let size = 1 + TOPIC_PONG.len().max(TOPIC_PING.len()) + payload.len();
+ let size = 1 + TOPIC.len() + payload.len();
assert!(
size + 50 <= MYCELIUM_CHUNK,
"keepalive with {MAX_LISTED_HEADS} heads takes {size} bytes"
);
- assert_eq!(bytes_to_keepalive(&payload).unwrap().heads, keepalive.heads);
+ let ParsedMessage::Pong(decoded) = wire::decode(&payload).unwrap() else {
+ panic!("not a PONG");
+ };
+ assert_eq!(decoded.heads, keepalive.heads);
}
#[test]
@@ -1228,7 +845,11 @@ mod tests {
id: [7; 32],
};
let id = BlobId::Change(ChangeHash([9; 32]));
- let chunk = BlobMessage {
+ let decode_blob = |payload: &[u8]| match wire::decode(payload).unwrap() {
+ ParsedMessage::Blob(blob) => blob,
+ other => panic!("not a blob: {other:?}"),
+ };
+ let chunk = ParsedMessage::Blob(BlobMessage {
epoch,
epoch_seq: 4,
id,
@@ -1236,11 +857,11 @@ mod tests {
index: 5,
data: vec![1u8; 64 * 1024].into(),
},
- };
- let bytes = blob_to_bytes(&chunk).unwrap();
+ });
+ let bytes = wire::encode(&chunk).unwrap();
// A 64 KiB chunk costs only a few bytes of framing.
assert!(bytes.len() < 64 * 1024 + 128, "{} bytes", bytes.len());
- let decoded = bytes_to_blob(&bytes).unwrap();
+ let decoded = decode_blob(&bytes);
assert_eq!(decoded.id, id);
assert_eq!(decoded.epoch, epoch);
assert_eq!(decoded.epoch_seq, 4);
@@ -1248,7 +869,7 @@ mod tests {
matches!(decoded.body, BlobBody::Chunk { index: 5, ref data } if data.len() == 64 * 1024)
);
- let manifest = BlobMessage {
+ let manifest = ParsedMessage::Blob(BlobMessage {
epoch,
epoch_seq: 4,
id,
@@ -1257,8 +878,8 @@ mod tests {
chunk_size: 64,
hashes: vec![[1; 32], [2; 32]],
},
- };
- let decoded = bytes_to_blob(&blob_to_bytes(&manifest).unwrap()).unwrap();
+ });
+ let decoded = decode_blob(&wire::encode(&manifest).unwrap());
assert!(matches!(
decoded.body,
BlobBody::Manifest { size: 100, chunk_size: 64, ref hashes } if hashes.len() == 2
@@ -1266,6 +887,22 @@ mod tests {
}
#[test]
+ fn messages_round_trip_through_the_api_body() {
+ let keepalive = ParsedMessage::Ping(Keepalive {
+ heads: HeadsSummary::of(vec![ChangeHash([1; 32])]),
+ epoch: Epoch::GENESIS,
+ epoch_seq: 0,
+ ready: true,
+ syncs_with_you: true,
+ });
+ let encoded = encode_message("peer", &keepalive).unwrap();
+ assert!(matches!(
+ decode_message(&encoded.body).unwrap(),
+ ParsedMessage::Ping(_)
+ ));
+ }
+
+ #[test]
fn listed_heads_equal_their_digest() {
let heads: Vec<_> = (0..3u8).map(|n| ChangeHash([n; 32])).collect();
let listed = HeadsSummary::of(heads.clone());
diff --git a/src/transport/wire.rs b/src/transport/wire.rs
new file mode 100644
index 0000000..8a8cbb8
--- /dev/null
+++ b/src/transport/wire.rs
@@ -0,0 +1,354 @@
+//! The wire format of a message: one byte saying what it is (`MessageKind`), then the
+//! message itself in MessagePack, as derived from its type in `transport` (structs as
+//! arrays, fields in declaration order). The codecs below make hashes, IDs and bytes
+//! compact (MessagePack binaries, not arrays of numbers) and check their lengths.
+//!
+//! The wire format may change freely until the first release: there is no compatibility
+//! with older nodes to keep.
+
+use thiserror::Error;
+
+use super::{MessageKind, ParsedMessage};
+
+/// What the first byte of a payload says.
+fn tag(kind: MessageKind) -> u8 {
+ match kind {
+ MessageKind::Hello => 0,
+ MessageKind::HelloReply => 1,
+ MessageKind::Sync => 2,
+ MessageKind::Ping => 3,
+ MessageKind::Pong => 4,
+ MessageKind::Blob => 5,
+ }
+}
+
+#[derive(Error, Debug)]
+pub enum WireError {
+ #[error("empty payload")]
+ Empty,
+
+ #[error("unknown message kind {0}")]
+ UnknownKind(u8),
+
+ #[error("MessagePack serialization failed: {0}")]
+ Encode(#[from] rmp_serde::encode::Error),
+
+ #[error("MessagePack deserialization failed: {0}")]
+ Decode(#[from] rmp_serde::decode::Error),
+}
+
+/// The payload of `message`.
+pub(crate) fn encode(message: &ParsedMessage) -> Result<Vec<u8>, WireError> {
+ let mut payload = vec![tag(message.kind())];
+ match message {
+ ParsedMessage::Hello(hello) => rmp_serde::encode::write(&mut payload, hello)?,
+ ParsedMessage::HelloReply(reply) => rmp_serde::encode::write(&mut payload, reply)?,
+ ParsedMessage::Sync(envelope) => rmp_serde::encode::write(&mut payload, envelope)?,
+ ParsedMessage::Ping(keepalive) | ParsedMessage::Pong(keepalive) => {
+ rmp_serde::encode::write(&mut payload, keepalive)?
+ }
+ ParsedMessage::Blob(blob) => rmp_serde::encode::write(&mut payload, blob)?,
+ }
+ Ok(payload)
+}
+
+/// The message in `payload` (as made by [`encode`]).
+pub(crate) fn decode(payload: &[u8]) -> Result<ParsedMessage, WireError> {
+ let (&kind, body) = payload.split_first().ok_or(WireError::Empty)?;
+ let is = |wanted: MessageKind| kind == tag(wanted);
+ Ok(if is(MessageKind::Hello) {
+ ParsedMessage::Hello(rmp_serde::from_slice(body)?)
+ } else if is(MessageKind::HelloReply) {
+ ParsedMessage::HelloReply(rmp_serde::from_slice(body)?)
+ } else if is(MessageKind::Sync) {
+ ParsedMessage::Sync(rmp_serde::from_slice(body)?)
+ } else if is(MessageKind::Ping) {
+ ParsedMessage::Ping(rmp_serde::from_slice(body)?)
+ } else if is(MessageKind::Pong) {
+ ParsedMessage::Pong(rmp_serde::from_slice(body)?)
+ } else if is(MessageKind::Blob) {
+ ParsedMessage::Blob(rmp_serde::from_slice(body)?)
+ } else {
+ return Err(WireError::UnknownKind(kind));
+ })
+}
+
+/// Field codecs (`#[serde(with = "...")]`) for the message types.
+pub(crate) mod codec {
+ use serde::de::Error as _;
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
+ use serde_bytes::{ByteBuf, Bytes};
+
+ /// Exactly `N` bytes, or a decoding error naming `what`.
+ fn fixed<const N: usize, E: serde::de::Error>(bytes: &[u8], what: &str) -> Result<[u8; N], E> {
+ bytes
+ .try_into()
+ .map_err(|_| E::custom(format!("{what} must be {N} bytes")))
+ }
+
+ /// A run ID (`RunId`).
+ pub(crate) mod run_id {
+ use super::*;
+
+ pub(crate) fn serialize<S: Serializer>(value: &[u8; 16], s: S) -> Result<S::Ok, S::Error> {
+ Bytes::new(value).serialize(s)
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 16], D::Error> {
+ fixed(&ByteBuf::deserialize(d)?, "a run id")
+ }
+ }
+
+ /// A 32-byte hash.
+ pub(crate) mod bytes32 {
+ use super::*;
+
+ pub(crate) fn serialize<S: Serializer>(value: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
+ Bytes::new(value).serialize(s)
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
+ fixed(&ByteBuf::deserialize(d)?, "a hash")
+ }
+ }
+
+ /// 32-byte hashes.
+ pub(crate) mod hashes32 {
+ use super::*;
+
+ pub(crate) fn serialize<S: Serializer>(
+ value: &[[u8; 32]],
+ s: S,
+ ) -> Result<S::Ok, S::Error> {
+ s.collect_seq(value.iter().map(|hash| Bytes::new(hash)))
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
+ d: D,
+ ) -> Result<Vec<[u8; 32]>, D::Error> {
+ Vec::<ByteBuf>::deserialize(d)?
+ .iter()
+ .map(|hash| fixed(hash, "a hash"))
+ .collect()
+ }
+ }
+
+ /// An Automerge change hash.
+ pub(crate) mod change_hash {
+ use super::*;
+ use automerge::ChangeHash;
+
+ pub(crate) fn serialize<S: Serializer>(
+ value: &ChangeHash,
+ s: S,
+ ) -> Result<S::Ok, S::Error> {
+ Bytes::new(&value.0).serialize(s)
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ChangeHash, D::Error> {
+ Ok(ChangeHash(fixed(
+ &ByteBuf::deserialize(d)?,
+ "a change hash",
+ )?))
+ }
+ }
+
+ /// An epoch: generation and ID.
+ pub(crate) mod epoch {
+ use super::*;
+ use crate::document::replica::Epoch;
+
+ pub(crate) fn serialize<S: Serializer>(value: &Epoch, s: S) -> Result<S::Ok, S::Error> {
+ (value.generation, Bytes::new(&value.id)).serialize(s)
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Epoch, D::Error> {
+ let (generation, id): (u64, ByteBuf) = Deserialize::deserialize(d)?;
+ Ok(Epoch {
+ generation,
+ id: fixed(&id, "an epoch id")?,
+ })
+ }
+ }
+
+ /// Heads as a keepalive carries them: listed (at most `MAX_LISTED_HEADS`, digest
+ /// empty), or as a digest (none listed).
+ pub(crate) mod heads {
+ use super::*;
+ use crate::transport::{HeadsSummary, MAX_LISTED_HEADS};
+ use automerge::ChangeHash;
+
+ pub(crate) fn serialize<S: Serializer>(
+ value: &HeadsSummary,
+ s: S,
+ ) -> Result<S::Ok, S::Error> {
+ let (listed, digest): (Vec<&Bytes>, &Bytes) = match value {
+ HeadsSummary::Heads(heads) => (
+ heads.iter().map(|head| Bytes::new(&head.0)).collect(),
+ Bytes::new(&[]),
+ ),
+ HeadsSummary::Digest(digest) => (Vec::new(), Bytes::new(digest)),
+ };
+ (listed, digest).serialize(s)
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
+ d: D,
+ ) -> Result<HeadsSummary, D::Error> {
+ let (listed, digest): (Vec<ByteBuf>, ByteBuf) = Deserialize::deserialize(d)?;
+ if !digest.is_empty() {
+ return Ok(HeadsSummary::Digest(fixed(&digest, "a heads digest")?));
+ }
+ if listed.len() > MAX_LISTED_HEADS {
+ return Err(D::Error::custom(format!(
+ "more than {MAX_LISTED_HEADS} heads listed"
+ )));
+ }
+ listed
+ .iter()
+ .map(|head| -> Result<ChangeHash, D::Error> {
+ Ok(ChangeHash(fixed(head, "a head")?))
+ })
+ .collect::<Result<Vec<_>, _>>()
+ .map(HeadsSummary::Heads)
+ }
+ }
+
+ /// An Automerge sync message, in Automerge's own encoding.
+ pub(crate) mod sync_message {
+ use super::*;
+ use automerge::sync::Message;
+
+ pub(crate) fn serialize<S: Serializer>(value: &Message, s: S) -> Result<S::Ok, S::Error> {
+ Bytes::new(&value.clone().encode()).serialize(s)
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Message, D::Error> {
+ Message::decode(&ByteBuf::deserialize(d)?)
+ .map_err(|error| D::Error::custom(format!("Automerge decode failed: {error:?}")))
+ }
+ }
+
+ /// Shared bytes (`bytes::Bytes`).
+ pub(crate) mod shared_bytes {
+ use super::*;
+
+ pub(crate) fn serialize<S: Serializer>(
+ value: &bytes::Bytes,
+ s: S,
+ ) -> Result<S::Ok, S::Error> {
+ Bytes::new(value).serialize(s)
+ }
+
+ pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
+ d: D,
+ ) -> Result<bytes::Bytes, D::Error> {
+ Ok(ByteBuf::deserialize(d)?.into_vec().into())
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::document::replica::Epoch;
+ use crate::transport::{
+ BlobBody, BlobId, BlobMessage, HeadsSummary, HelloReply, HelloRequest, Keepalive,
+ MAX_LISTED_HEADS,
+ };
+ use automerge::ChangeHash;
+
+ #[test]
+ fn handshakes_round_trip() {
+ let epoch = Epoch {
+ generation: 2,
+ id: [3; 32],
+ };
+ let hello = ParsedMessage::Hello(HelloRequest {
+ swarm_id: Some("swarm".into()),
+ epoch,
+ epoch_seq: 1,
+ auth: vec![1, 2, 3],
+ ready: true,
+ run_id: [4; 16],
+ });
+ let ParsedMessage::Hello(decoded) = decode(&encode(&hello).unwrap()).unwrap() else {
+ panic!("not a HELLO");
+ };
+ assert_eq!(decoded.swarm_id.as_deref(), Some("swarm"));
+ assert_eq!((decoded.epoch, decoded.epoch_seq), (epoch, 1));
+ assert_eq!(decoded.auth, vec![1, 2, 3]);
+ assert_eq!(decoded.run_id, [4; 16]);
+
+ let reply = ParsedMessage::HelloReply(HelloReply {
+ swarm_id: "swarm".into(),
+ peers: vec!["p".into()],
+ epoch,
+ epoch_seq: 1,
+ auth: Vec::new(),
+ ready: false,
+ run_id: [4; 16],
+ away_ms: Some(5),
+ sender_run_id: [6; 16],
+ });
+ let ParsedMessage::HelloReply(decoded) = decode(&encode(&reply).unwrap()).unwrap() else {
+ panic!("not a HELLO_REPLY");
+ };
+ assert_eq!(decoded.peers, vec!["p".to_owned()]);
+ assert_eq!(decoded.away_ms, Some(5));
+ assert_eq!(decoded.sender_run_id, [6; 16]);
+ }
+
+ #[test]
+ fn keepalives_keep_their_kind_and_heads() {
+ let keepalive = Keepalive {
+ heads: HeadsSummary::Digest([8; 32]),
+ epoch: Epoch::GENESIS,
+ epoch_seq: 0,
+ ready: true,
+ syncs_with_you: false,
+ };
+ let decoded = decode(&encode(&ParsedMessage::Pong(keepalive.clone())).unwrap()).unwrap();
+ let ParsedMessage::Pong(decoded) = decoded else {
+ panic!("not a PONG");
+ };
+ assert_eq!(decoded.heads, keepalive.heads);
+ }
+
+ #[test]
+ fn too_many_listed_heads_are_refused() {
+ let keepalive = Keepalive {
+ heads: HeadsSummary::Heads(
+ (0..=MAX_LISTED_HEADS as u8)
+ .map(|n| ChangeHash([n; 32]))
+ .collect(),
+ ),
+ epoch: Epoch::GENESIS,
+ epoch_seq: 0,
+ ready: true,
+ syncs_with_you: true,
+ };
+ assert!(decode(&encode(&ParsedMessage::Ping(keepalive)).unwrap()).is_err());
+ }
+
+ #[test]
+ fn unknown_kinds_and_bad_payloads_are_refused() {
+ assert!(matches!(decode(&[]), Err(WireError::Empty)));
+ assert!(matches!(decode(&[99]), Err(WireError::UnknownKind(99))));
+
+ let blob = ParsedMessage::Blob(BlobMessage {
+ epoch: Epoch::GENESIS,
+ epoch_seq: 0,
+ id: BlobId::Content([1; 32]),
+ body: BlobBody::Missing,
+ });
+ let payload = encode(&blob).unwrap();
+ assert!(matches!(decode(&payload).unwrap(), ParsedMessage::Blob(_)));
+ // A payload cut short doesn't decode.
+ assert!(decode(&payload[..payload.len() - 1]).is_err());
+ // Nor one of another kind.
+ let mut relabelled = payload.clone();
+ relabelled[0] = tag(MessageKind::Hello);
+ assert!(decode(&relabelled).is_err());
+ }
+}