aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md10
-rw-r--r--docs/example.toml6
-rw-r--r--docs/lifecycle.md2
-rw-r--r--docs/openapi.yaml4
-rw-r--r--src/access/screen.rs63
-rw-r--r--src/config/mod.rs15
-rw-r--r--src/main.rs1
-rw-r--r--src/node.rs11
-rw-r--r--src/remake/api.rs7
-rw-r--r--src/simtest/mod.rs1
-rw-r--r--src/simtest/timing.rs2
-rw-r--r--src/state/mod.rs4
-rw-r--r--src/swarm/bootstrap.rs10
-rw-r--r--src/swarm/mod.rs1
-rw-r--r--src/swarm/peer/handshake.rs6
-rw-r--r--src/swarm/peer/inbound.rs12
-rw-r--r--src/swarm/roots.rs171
-rw-r--r--src/transport/mod.rs9
-rw-r--r--src/transport/wire.rs12
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")
)