diff options
| -rw-r--r-- | docs/access.md | 10 | ||||
| -rw-r--r-- | docs/example.toml | 6 | ||||
| -rw-r--r-- | docs/lifecycle.md | 2 | ||||
| -rw-r--r-- | docs/openapi.yaml | 4 | ||||
| -rw-r--r-- | src/access/screen.rs | 63 | ||||
| -rw-r--r-- | src/config/mod.rs | 15 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/node.rs | 11 | ||||
| -rw-r--r-- | src/remake/api.rs | 7 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 1 | ||||
| -rw-r--r-- | src/simtest/timing.rs | 2 | ||||
| -rw-r--r-- | src/state/mod.rs | 4 | ||||
| -rw-r--r-- | src/swarm/bootstrap.rs | 10 | ||||
| -rw-r--r-- | src/swarm/mod.rs | 1 | ||||
| -rw-r--r-- | src/swarm/peer/handshake.rs | 6 | ||||
| -rw-r--r-- | src/swarm/peer/inbound.rs | 12 | ||||
| -rw-r--r-- | src/swarm/roots.rs | 171 | ||||
| -rw-r--r-- | src/transport/mod.rs | 9 | ||||
| -rw-r--r-- | src/transport/wire.rs | 12 |
19 files changed, 336 insertions, 11 deletions
diff --git a/docs/access.md b/docs/access.md index c5365b7..782724a 100644 --- a/docs/access.md +++ b/docs/access.md @@ -318,6 +318,16 @@ policy's own change and everything after it, as every other node does. While nei its document nor what it receives holds a policy, it judges only that a root is the epoch's, and nothing costs more than applying the changes. +An epoch's root is pinned. A remake's is its epoch's ID, the hash of its snapshot. The +first epoch's is its genesis change, which its genesis node logs (`The swarm's root`) +and `GET /admin/epochs` shows (`root`). Every node names its root in its handshakes: a +node whose document starts from another root of the same epoch refuses the handshake +(another history under the swarm's ID: a forged root, or a genesis started again). +A node joining with an empty document takes as its root only the one it was given +(`--genesis-root`), or else the one its peers name, and none while they name different +ones. So one peer hands a joiner another root only if it is all the joiner hears, or +never, with `--genesis-root`. + What it doesn't do yet: * **Prove the user.** A change is its node's (with signatures required, proven) and its diff --git a/docs/example.toml b/docs/example.toml index d5af848..70d65b2 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -105,6 +105,12 @@ htpasswd = "/etc/continuum/htpasswd" # together at most. # key-logins-per-user = 100 +# The hash of the swarm's genesis change (hex, as its genesis node logs it and GET +# /admin/epochs shows it): joining with an empty document, the only root this node +# takes. Without, the one its peers name, none while they name different ones +# (docs/access.md). +# genesis-root = "…" + # SSH keys, as authorized_keys lines (ssh-ed25519 <base64> [comment]), whose signature # on a strict remake's manifest this node takes: the values kept when the swarm turns # strict (docs/access.md). Without, it votes for no strict remake; a majority of the diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 9fe9442..829e425 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -62,7 +62,7 @@ Then the node is either ready at once or joining: | Start | Without a remembered swarm | With one (state file) | |---|---|---| -| `--genesis` | a new swarm, ready at once | that swarm again, empty; nodes of it still running merge with this one | +| `--genesis` | a new swarm, ready at once | that swarm again, empty, from a new root, which nodes of it still running refuse (to join them, give seeds) | | document file holding a document | ready at once, on the document's swarm and epoch | the same; the two must name the same swarm | | `--recover-from` | ready at once, on the backup's swarm and epoch | the same; the two must name the same swarm | | seeds, or none | joining through the seeds | joining that swarm, through the seeds and the remembered members | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bb017f0..4a49d2b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3100,6 +3100,10 @@ components: properties: current: $ref: '#/components/schemas/Epoch' + root: + type: string + nullable: true + description: The hash of the epoch's root (hex), where the document holds it - for the first epoch, its genesis change, which a joining node may be given (`--genesis-root`). archived: type: array items: diff --git a/src/access/screen.rs b/src/access/screen.rs index 758c83b..690e032 100644 --- a/src/access/screen.rs +++ b/src/access/screen.rs @@ -75,6 +75,20 @@ pub(crate) struct Screening { /// Changes this node holds that peers say the swarm's rules refuse, and its own do /// too, whose drop would take much: each with the peers that said so (`hold_drop`). held_drops: HashMap<ChangeHash, BTreeSet<String>>, + /// Which change an empty document of the first epoch takes as its root + /// (`swarm::roots`); kept from one document to the next. + pub(crate) root_pin: RootPin, +} + +/// Which change an empty document of the first epoch takes as its root: any (nothing +/// heard of it, nor given), the one given (`--genesis-root`) or that its peers name, or +/// none while they name different ones. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) enum RootPin { + #[default] + Any, + Only(ChangeHash), + Disputed, } /// A member refused as a whole: since when, for which change of its, and why. @@ -116,11 +130,20 @@ impl Refusals { } impl Screening { + /// Empty, with `root_pin`. + pub(crate) fn pinned(root_pin: RootPin) -> Self { + Screening { + root_pin, + ..Screening::default() + } + } + /// Itself for the document `epoch_seq`: emptied if it was another's. pub(crate) fn of(&mut self, epoch_seq: u32) -> &mut Self { if self.epoch_seq != epoch_seq { *self = Screening { epoch_seq, + root_pin: std::mem::take(&mut self.root_pin), ..Screening::default() }; } @@ -629,10 +652,48 @@ pub(crate) fn apply_bytes( if let Some(misplaced) = crate::document::changes::misplaced_author(bytes) { anyhow::bail!("Refused changes: {misplaced}"); } - let changes = crate::document::changes::changes_in(bytes) + let mut changes = crate::document::changes::changes_in(bytes) .ok_or_else(|| anyhow::anyhow!("Refused changes that don't load"))?; let mut screening = screening.lock_or_recover(); let screening = screening.of(epoch_seq); + // Into an empty document of the first epoch, a root only as pinned: one given or + // named by the peers, none while they disagree (left for later). What builds on a + // root refused is refused with it; on one left, it waits. + let mut pinned_out = Vec::new(); + if epoch.generation == 0 && doc.get_heads().is_empty() && screening.root_pin != RootPin::Any { + changes.retain(|change| { + if !change.deps().is_empty() { + return true; + } + match &screening.root_pin { + RootPin::Only(root) if *root == change.hash() => true, + RootPin::Only(root) => { + pinned_out.push(( + change.clone(), + format!("a root other than the swarm's ({root})"), + )); + false + } + _ => false, + } + }); + } + let mut refused_roots = Vec::new(); + for (change, why) in pinned_out { + screening.refusals.insert(change.hash(), change.hash()); + refused_roots.push(Refused { + committer: change.author().and_then(|author| { + std::str::from_utf8(author.as_bytes()) + .ok() + .map(str::to_owned) + }), + user: None, + paths: BTreeSet::new(), + why, + change, + }); + } + screening.record(refused_roots); if changes.is_empty() && screening.waiting.len() == 0 { return Ok(Outcome::default()); } diff --git a/src/config/mod.rs b/src/config/mod.rs index 0352832..f6d3e15 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -180,6 +180,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "With bootstrap-users: their SSH keys, as authorized_keys lines (ssh-ed25519 <base64> [comment], named by the comment), recorded in their entries: to log in by key and sign values (docs/access.md), so a swarm can be strict from its start.", ), option( + "genesis-root", + Kind::Text, + "HASH", + None, + "The hash of the swarm's genesis change (hex, as its genesis node logs it and GET /admin/epochs shows it): joining with an empty document, the only root this node takes. Without, the one its peers name, none while they name different ones (docs/access.md).", + ), + option( "strict-keys", Kind::List, "KEY,[KEY...]", @@ -219,7 +226,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::Flag, "BOOL", Some("false"), - "Start a new swarm, as its genesis node, instead of joining one through seeds. Every start with it starts another new swarm: drop it once the swarm exists, and give the node seeds instead. Can't be combined with seeds. With a --state-file that remembers a swarm, it starts that swarm again instead (empty; nodes of it still running merge with this one).", + "Start a new swarm, as its genesis node, instead of joining one through seeds. Every start with it starts another new swarm: drop it once the swarm exists, and give the node seeds instead. Can't be combined with seeds. With a --state-file that remembers a swarm, it starts that swarm again instead (empty, from a new root, which nodes of it still running refuse: to join them, give seeds).", ), option( "force", @@ -601,6 +608,7 @@ pub(crate) struct Settings { pub(crate) bootstrap_users: Vec<String>, #[serde(default)] pub(crate) bootstrap_keys: Vec<String>, + pub(crate) genesis_root: Option<String>, #[serde(default)] pub(crate) strict_keys: Vec<String>, @@ -881,6 +889,11 @@ impl Settings { { bail!("{why}"); } + if let Some(root) = &self.genesis_root + && crate::swarm::roots::parse(root).is_none() + { + bail!("genesis-root: {root:?} isn't a change hash (64 hex digits)"); + } if let Some(why) = self .strict_keys .iter() diff --git a/src/main.rs b/src/main.rs index f632fe9..8c12f41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -260,6 +260,7 @@ async fn main() -> Result<()> { token_lifetime: args.token_lifetime, session_lifetime: args.session_lifetime, challenge_lifetime: args.challenge_lifetime, + genesis_root: args.genesis_root.as_deref().and_then(swarm::roots::parse), strict_keys: args .strict_keys .iter() diff --git a/src/node.rs b/src/node.rs index ddb9b7e..84a5c01 100644 --- a/src/node.rs +++ b/src/node.rs @@ -78,6 +78,8 @@ pub(crate) struct NodeConfig { /// `--challenge-lifetime`, `--key-logins-per-user`. pub(crate) challenge_lifetime: Duration, pub(crate) key_logins_per_user: usize, + /// `--genesis-root`. + pub(crate) genesis_root: Option<crate::document::ChangeHash>, /// `--strict-keys`. pub(crate) strict_keys: Vec<crate::access::sshsig::PublicKey>, /// `--cors-origins`. @@ -167,7 +169,14 @@ impl Node { first_users: Arc::new(config.first_users), node_key: config.node_key, sign_every: config.sign_every, - screening: Default::default(), + screening: Arc::new(std::sync::Mutex::new( + crate::access::screen::Screening::pinned(crate::swarm::roots::pin( + config.genesis_root, + &Default::default(), + )), + )), + roots: Default::default(), + genesis_root: config.genesis_root, challenges: Default::default(), token_lifetime: config.token_lifetime, session_lifetime: config.session_lifetime, diff --git a/src/remake/api.rs b/src/remake/api.rs index 1da0539..a767706 100644 --- a/src/remake/api.rs +++ b/src/remake/api.rs @@ -881,9 +881,14 @@ pub(crate) async fn strict_manifest<H: Host>( /// `GET /admin/epochs`: the local epoch, the previous epochs this node still keeps, and /// an ongoing catch-up with a peer's newer epoch (`null` if none). pub(crate) async fn epochs<H: Host>(State(host): State<H>) -> Response { - let current = EpochRecord::from(host.current_epoch()); + let epoch = host.current_epoch(); + let current = EpochRecord::from(epoch); + let root = host + .read_doc(move |doc| crate::swarm::roots::of(doc, epoch).map(|root| root.to_string())) + .await; Json(json!({ "current": current, + "root": root, "archived": host.remake().archived_epochs(), "catchUp": host.remake().catch_up_report(), "quarantine": host.remake().list_quarantine(|entry| entry.id.clone()), diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index 6921ed6..253ec54 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -222,6 +222,7 @@ impl Sim { session_lifetime: Duration::from_secs(86400), challenge_lifetime: Duration::from_secs(300), key_logins_per_user: 100, + genesis_root: None, strict_keys: Vec::new(), cors_origins: Vec::new(), files: Arc::new(Files::default()), diff --git a/src/simtest/timing.rs b/src/simtest/timing.rs index 25dee02..5f93acc 100644 --- a/src/simtest/timing.rs +++ b/src/simtest/timing.rs @@ -282,6 +282,7 @@ async fn resent_hello_keeps_the_session() { ready: true, run_id: run, handshake, + root: None, }) }; for repeated in [handshake, handshake.saturating_sub(1)] { @@ -325,6 +326,7 @@ async fn late_epoch_does_not_flip_a_peers_epoch() { ready: true, run_id: run, handshake: 1, + root: None, }) }; let status = |epoch: Epoch, epoch_seq: u32| { diff --git a/src/state/mod.rs b/src/state/mod.rs index 441cdaf..d3f5859 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -161,6 +161,10 @@ pub(crate) struct AppState { /// may make within it (`access::challenge`). pub(crate) challenge_lifetime: Duration, pub(crate) key_logins_per_user: usize, + /// Its epoch's root, as peers name it and as it knows its own (`swarm::roots`), and + /// the one it was given (`--genesis-root`). + pub(crate) roots: Arc<std::sync::Mutex<crate::swarm::roots::Roots>>, + pub(crate) genesis_root: Option<crate::document::ChangeHash>, /// Whose signature on a strict remake's manifest this node takes (`access::strict`). pub(crate) strict_keys: Arc<Vec<crate::access::sshsig::PublicKey>>, /// Origins whose pages may call this node's open endpoints (`api::cors`). diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs index 98e273a..4384edb 100644 --- a/src/swarm/bootstrap.rs +++ b/src/swarm/bootstrap.rs @@ -70,13 +70,14 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start) return Ok(()); } if start == Start::Genesis { - // A remembered swarm starts again under its ID: nodes of it still running then - // merge with this one instead of ending up in another swarm. + // A remembered swarm starts again under its ID, from a root of its own: nodes of + // it still running hold another, and refuse it (`swarm::roots`); joining them + // takes seeds instead. let swarm_id = match state.swarm_id().await { Some(remembered) => { warn!( swarm_id = %remembered, - "--genesis with a remembered swarm: starting it again, empty; nodes of it still running merge with this one" + "--genesis with a remembered swarm: starting it again, empty, from a new root; nodes of it still running refuse it (another root): give seeds to join them instead" ); remembered } @@ -131,6 +132,9 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start) .context( "Recording the swarm ID, its neighbours, its topology and dissemination, its first users", )?; + if let Some(root) = crate::swarm::roots::own(state).await { + info!(root = %root, "The swarm's root: a node joining may be given it (--genesis-root), to take no other"); + } state.initial_sync_done.store(true, Ordering::Release); state.mark_ready(swarm_id).await?; // Remembered members, if any, to find those still running. diff --git a/src/swarm/mod.rs b/src/swarm/mod.rs index d872474..e08bc62 100644 --- a/src/swarm/mod.rs +++ b/src/swarm/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod plumtree; pub(crate) mod reach; pub(crate) mod refusals; pub(crate) mod relay; +pub(crate) mod roots; pub(crate) mod rtt; pub(crate) mod signatures; pub(crate) mod skew; diff --git a/src/swarm/peer/handshake.rs b/src/swarm/peer/handshake.rs index b964064..8e9b0ef 100644 --- a/src/swarm/peer/handshake.rs +++ b/src/swarm/peer/handshake.rs @@ -139,6 +139,9 @@ async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> { ready: state.is_ready().await, run_id: state.run_id, handshake, + root: crate::swarm::roots::own(state) + .await + .map(|root| root.0.to_vec()), }), ) .await; @@ -236,6 +239,9 @@ pub(super) async fn send_hello_reply( run_id, away_ms: away.map(|away| away.as_millis() as u64), sender_run_id: state.run_id, + root: crate::swarm::roots::own(state) + .await + .map(|root| root.0.to_vec()), }; debug!(to = %peer, ready, "Sending HELLO_REPLY"); diff --git a/src/swarm/peer/inbound.rs b/src/swarm/peer/inbound.rs index a7633b3..3ddaf08 100644 --- a/src/swarm/peer/inbound.rs +++ b/src/swarm/peer/inbound.rs @@ -96,6 +96,18 @@ pub(crate) async fn handle_message( return Ok(()); } + // The root its document starts from: another than ours, of our epoch, is another + // history; what a joiner takes as its root follows those named (`swarm::roots`). + match &message { + ParsedMessage::Hello(hello) => { + crate::swarm::roots::heard(state, &peer, epoch, hello.root.as_deref()).await? + } + ParsedMessage::HelloReply(reply) => { + crate::swarm::roots::heard(state, &peer, epoch, reply.root.as_deref()).await? + } + _ => {} + } + remember_peer(state, &peer).await; let run = match &message { diff --git a/src/swarm/roots.rs b/src/swarm/roots.rs new file mode 100644 index 0000000..5089418 --- /dev/null +++ b/src/swarm/roots.rs @@ -0,0 +1,171 @@ +//! An epoch's root, pinned: the change without dependencies a document of that epoch +//! starts from. For a remake's epoch it is the epoch's ID (its snapshot change's hash), +//! which the rules hold every root to; for the first epoch, whose ID is all zeros, the +//! genesis change, which a node joining with an empty document would take from whoever +//! sent it first. So every node names its epoch's root in its handshakes +//! (`HelloRequest::root`, `HelloReply::root`), and: +//! +//! * a node whose document holds another root of the same epoch refuses the handshake: +//! another history under this swarm's ID (a forged root, or a genesis started again); +//! * a joining node takes as its root only the one given (`--genesis-root`), or else the +//! one its peers name, and none while they name different ones (`RootPin`), so one +//! peer can't hand it another root unless it is all the joiner hears. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Result, bail}; +use tracing::{error, info, warn}; + +use crate::access::screen::RootPin; +use crate::document::replica::Epoch; +use crate::document::{ChangeHash, Document}; +use crate::locks::MutexExt; +use crate::state::AppState; + +/// What the node heard of its epoch's root, and knows of its own. +#[derive(Debug, Default)] +pub(crate) struct Roots { + /// Of the first epoch, as each peer named it. + named: HashMap<String, ChangeHash>, + /// Its own, for the document (`Replica::epoch_seq`), once known. + own: Option<(u32, ChangeHash)>, + /// Peers whose root differs, logged once each. + differing: HashSet<String>, +} + +/// A change hash in hex (`--genesis-root`). +pub(crate) fn parse(hex: &str) -> Option<ChangeHash> { + let hex = hex.trim(); + if hex.len() != 64 { + return None; + } + let mut bytes = [0u8; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(hex.get(index * 2..index * 2 + 2)?, 16).ok()?; + } + Some(ChangeHash(bytes)) +} + +/// `doc`'s root, of `epoch`: the epoch's ID for a remake's, the change without +/// dependencies of the first epoch's; None for an empty document. +pub(crate) fn of(doc: &Document, epoch: Epoch) -> Option<ChangeHash> { + if epoch.generation > 0 { + return Some(ChangeHash(epoch.id)); + } + if doc.get_heads().is_empty() { + return None; + } + doc.get_changes(&[]) + .iter() + .find(|change| change.deps().is_empty()) + .map(|change| change.hash()) +} + +/// This node's epoch's root, if its document holds one; found once per document. +pub(crate) async fn own(state: &AppState) -> Option<ChangeHash> { + let (epoch, epoch_seq) = state.epoch_stamp(); + if epoch.generation > 0 { + return Some(ChangeHash(epoch.id)); + } + if let Some((seq, root)) = state.roots.lock_or_recover().own + && seq == epoch_seq + { + return Some(root); + } + let replica = state.doc.read().await; + let root = of(&replica.doc, replica.epoch)?; + state.roots.lock_or_recover().own = Some((replica.epoch_seq, root)); + Some(root) +} + +/// `peer`, in a handshake of `epoch`, names `root` as its root. Refused (an error) if +/// this node's document holds another of the same epoch; of the first epoch, what an +/// empty document takes as its root follows (`pin`). +pub(crate) async fn heard( + state: &AppState, + peer: &str, + epoch: Epoch, + root: Option<&[u8]>, +) -> Result<()> { + let Some(root) = root.and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()) else { + return Ok(()); + }; + let root = ChangeHash(root); + if epoch != state.epoch_stamp().0 { + // Another epoch: catching up is its business. + return Ok(()); + } + if let Some(ours) = own(state).await + && ours != root + { + if state + .roots + .lock_or_recover() + .differing + .insert(peer.to_owned()) + { + error!(peer = %peer, ours = %ours, theirs = %root, epoch = %epoch, "A peer's document starts from another root of this epoch: another history under this swarm's ID (a forged root, or a genesis started again); not synced with"); + } + bail!("{peer} starts epoch {epoch} from root {root}, ours from {ours}"); + } + if epoch.generation == 0 { + let pin = { + let mut roots = state.roots.lock_or_recover(); + roots.named.insert(peer.to_owned(), root); + pin(state.genesis_root, &roots.named) + }; + let mut screening = state.screening.lock_or_recover(); + if screening.root_pin != pin { + match &pin { + RootPin::Disputed => { + warn!( + "Peers name different roots of the swarm's first epoch: taking none until they agree, or one is given (--genesis-root)" + ); + } + RootPin::Only(root) => { + info!(root = %root, "The swarm's root, as given or as its peers name it") + } + RootPin::Any => {} + } + screening.root_pin = pin; + } + } + Ok(()) +} + +/// What an empty document of the first epoch takes as its root: the one `given`, else +/// the one all peers name (`named`), none while they differ, any while none is named. +pub(crate) fn pin(given: Option<ChangeHash>, named: &HashMap<String, ChangeHash>) -> RootPin { + if let Some(given) = given { + return RootPin::Only(given); + } + let mut roots: Vec<&ChangeHash> = named.values().collect(); + roots.sort_unstable(); + roots.dedup(); + match roots.as_slice() { + [] => RootPin::Any, + [root] => RootPin::Only(**root), + _ => RootPin::Disputed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The root given wins; else the one every peer names; none while they differ. + #[test] + fn what_a_joiner_takes_as_its_root() { + let (a, b) = (ChangeHash([1; 32]), ChangeHash([2; 32])); + let named = |roots: &[(&str, ChangeHash)]| -> HashMap<String, ChangeHash> { + roots + .iter() + .map(|(peer, root)| ((*peer).to_owned(), *root)) + .collect() + }; + assert_eq!(pin(None, &named(&[])), RootPin::Any); + assert_eq!(pin(None, &named(&[("p", a), ("q", a)])), RootPin::Only(a)); + assert_eq!(pin(None, &named(&[("p", a), ("q", b)])), RootPin::Disputed); + assert_eq!(pin(Some(b), &named(&[("p", a)])), RootPin::Only(b)); + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index e0359b0..640754a 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -576,6 +576,10 @@ pub(crate) struct HelloRequest { /// with all peers, from 1. A retry carries the same number, so the recipient resets /// its sync session only for a new handshake, not for every resend or late copy. pub(crate) handshake: u32, + /// The hash of its epoch's root, where its document holds it (`swarm::roots`): what + /// a joining node takes as the root, where those it hears agree. + #[serde(with = "serde_bytes")] + pub(crate) root: Option<Vec<u8>>, } /// A peer a HELLO_REPLY lists: who it is, and where the sender reaches it. @@ -661,6 +665,9 @@ pub(crate) struct HelloReply { /// 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(crate) sender_run_id: RunId, + /// As `HelloRequest::root`. + #[serde(with = "serde_bytes")] + pub(crate) root: Option<Vec<u8>>, } /// Bytes shown as lowercase hex, e.g. run IDs in logs. @@ -694,6 +701,7 @@ impl std::fmt::Debug for HelloRequest { .field("ready", &self.ready) .field("run_id", &Hex(&self.run_id)) .field("handshake", &self.handshake) + .field("root", &self.root.as_deref().map(Hex)) .finish() } } @@ -710,6 +718,7 @@ impl std::fmt::Debug for HelloReply { .field("run_id", &Hex(&self.run_id)) .field("away_ms", &self.away_ms) .field("sender_run_id", &Hex(&self.sender_run_id)) + .field("root", &self.root.as_deref().map(Hex)) .finish() } } diff --git a/src/transport/wire.rs b/src/transport/wire.rs index 2ab39e5..5763407 100644 --- a/src/transport/wire.rs +++ b/src/transport/wire.rs @@ -14,7 +14,7 @@ use super::{EncodedMessage, MessageKind, ParsedMessage}; /// The wire format's version, the first byte of every payload. Bump it with every change /// to a message type or a codec (`golden_bytes` fails then). -pub(crate) const VERSION: u8 = 44; +pub(crate) const VERSION: u8 = 45; /// What the second byte of a payload says. fn tag(kind: MessageKind) -> u8 { @@ -461,6 +461,7 @@ pub(crate) fn samples() -> Vec<ParsedMessage> { ready: true, run_id: [4; 16], handshake: 7, + root: None, }), ParsedMessage::HelloReply(HelloReply { swarm_id: "swarm".into(), @@ -475,6 +476,7 @@ pub(crate) fn samples() -> Vec<ParsedMessage> { run_id: [4; 16], away_ms: Some(5), sender_run_id: [6; 16], + root: None, }), ParsedMessage::Sync(SyncEnvelope { epoch: crate::transport::EpochTag::of(epoch), @@ -607,6 +609,7 @@ mod tests { ready: true, run_id: [4; 16], handshake: 7, + root: None, }); let ParsedMessage::Hello(decoded) = decode(&encode(&hello).unwrap()).unwrap() else { panic!("not a HELLO"); @@ -630,6 +633,7 @@ mod tests { run_id: [4; 16], away_ms: Some(5), sender_run_id: [6; 16], + root: None, }); let ParsedMessage::HelloReply(decoded) = decode(&encode(&reply).unwrap()).unwrap() else { panic!("not a HELLO_REPLY"); @@ -745,11 +749,12 @@ mod tests { ready: true, run_id: [4; 16], handshake: 7, + root: None, }); assert_eq!( hex(&encode(&hello).unwrap()), format!( - "{v}0097a5737761726d{epoch_hex}01c403010203c3{}07", + "{v}0098a5737761726d{epoch_hex}01c403010203c3{}07c0", run("04") ) ); @@ -767,11 +772,12 @@ mod tests { run_id: [4; 16], away_ms: Some(5), sender_run_id: [6; 16], + root: None, }); assert_eq!( hex(&encode(&reply).unwrap()), format!( - "{v}0199a5737761726d9192a17091a161{epoch_hex}01c400c2{}05{}", + "{v}019aa5737761726d9192a17091a161{epoch_hex}01c400c2{}05{}c0", run("04"), run("06") ) |
