Establishes VERSIONING.md: SemVer 0.x (MINOR = breaking wire change) for the release version, and per-plane protocol versions enforced on the wire so incompatible peers fail fast and legibly instead of via silent decode/signature errors. ⚠️ BREAKING WIRE CHANGE — all peers must run >= 0.2.0 to interoperate (ALPNs and gossip subscription topics changed). A pre-0.2.0 peer (e.g. an un-resynced dopedart) can no longer connect, by design, and now fails at the handshake. - New src/protocol.rs: single source of truth for AUDIO/FRIENDS/GOSSIP_PROTO, the derived ALPNs (peerspeak/audio/1, peerspeak/friends/1), GOSSIP_SIG_DOMAIN, and versioned_topic(). Unit tests assert ALPN/domain strings match their integer versions (no silent drift) + that topic namespacing is deterministic. - Unified ALPNs: audio was b"peerspeak-audio" (unversioned, and duplicated in iroh_impl.rs + core/mod.rs) -> peerspeak/audio/1 from protocol.rs; friends re-exports protocol::FRIENDS_ALPN (was peerspeak/friends/0 -> /1). - Gossip: subscribe to versioned_topic(ticket.topic_id) so different gossip versions never share a swarm; the raw topic_id stays the room identity and what signatures bind. GOSSIP_SIG_DOMAIN centralized into protocol.rs. - Cargo.toml 0.1.0 -> 0.2.0. 316 lib tests / clippy --all-targets clean. VERSIONING.md documents the bump rules, the "I changed X -> what do I bump" table, and a release checklist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
83 lines
3.8 KiB
Rust
83 lines
3.8 KiB
Rust
//! Single source of truth for PeerSpeak's on-wire protocol versions and the
|
|
//! per-plane ALPNs / gossip constants derived from them.
|
|
//!
|
|
//! See `VERSIONING.md`. The rule: each transport plane is versioned independently
|
|
//! (audio rarely changes, gossip changes often), and incompatible peers must fail
|
|
//! fast — never as a silent decode/signature error. iroh refuses a mismatched
|
|
//! ALPN at the QUIC handshake, so the audio/friends planes are self-isolating;
|
|
//! gossip can't use a custom ALPN (it rides iroh-gossip's `GOSSIP_ALPN`), so its
|
|
//! version is bound into the subscribed topic ([`versioned_topic`]) and the
|
|
//! signature domain ([`GOSSIP_SIG_DOMAIN`]).
|
|
//!
|
|
//! **Never hand-write an ALPN literal elsewhere — derive it here.** Bumping a
|
|
//! plane's protocol version is a breaking wire change → also bump `Cargo.toml`
|
|
//! MINOR (see `VERSIONING.md`).
|
|
|
|
/// Audio datagram plane version (Opus framing / sequencing). Bump on any audio
|
|
/// wire change. Mirrored in [`AUDIO_ALPN`].
|
|
pub const AUDIO_PROTO: u32 = 1;
|
|
/// Friends/presence plane version (`ControlMsg` ping-pong shape). Bump on any
|
|
/// change. Mirrored in [`FRIENDS_ALPN`].
|
|
pub const FRIENDS_PROTO: u32 = 1;
|
|
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
|
|
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
|
|
/// into [`versioned_topic`].
|
|
pub const GOSSIP_PROTO: u32 = 1;
|
|
|
|
/// ALPN for the audio datagram plane: `peerspeak/audio/<AUDIO_PROTO>`.
|
|
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
|
|
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
|
|
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
|
|
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
|
/// the gossip protocol version into every signed payload — a version mismatch
|
|
/// fails verification (cryptographic separation between gossip versions).
|
|
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
|
|
|
|
/// Version-namespace a room topic so peers on different gossip protocol versions
|
|
/// derive **different subscription topics from the same ticket** and therefore
|
|
/// never share a swarm — the gossip analog of a versioned ALPN. The room's raw
|
|
/// `topic_id` (random 32 bytes, carried in the ticket) is the room identity and
|
|
/// is unchanged; only the *subscribed* topic is namespaced.
|
|
///
|
|
/// Deterministic and dependency-free; bijective for a fixed version, so distinct
|
|
/// rooms stay distinct after namespacing. This transform is for *isolation*, not
|
|
/// security — cryptographic separation between versions comes from
|
|
/// [`GOSSIP_SIG_DOMAIN`].
|
|
pub fn versioned_topic(topic_id: [u8; 32]) -> [u8; 32] {
|
|
let v = GOSSIP_PROTO.to_le_bytes();
|
|
let mut out = topic_id;
|
|
for (i, b) in out.iter_mut().enumerate() {
|
|
*b ^= v[i % v.len()];
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The ALPN/domain strings must stay in lock-step with the integer versions
|
|
/// so a version bump can't silently forget to update the wire string.
|
|
#[test]
|
|
fn alpns_match_their_proto_versions() {
|
|
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
|
|
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
|
|
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
|
|
}
|
|
|
|
#[test]
|
|
fn versioned_topic_is_deterministic_and_room_distinct() {
|
|
let a = [9u8; 32];
|
|
let mut b = a;
|
|
b[5] = 10;
|
|
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
|
|
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct");
|
|
}
|
|
|
|
#[test]
|
|
fn versioned_topic_actually_namespaces_for_current_version() {
|
|
// Guards against a no-op transform: GOSSIP_PROTO=1 must change the topic.
|
|
assert_ne!(versioned_topic([0u8; 32]), [0u8; 32]);
|
|
}
|
|
}
|