blob: adf1ba597392fa7ad1700ad92bf06ebba72c4876 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Binary protocol versioning.
//!
//! The wire version is an explicit packed semver, maintained manually for
//! stable Iggy releases and independent of server and SDK package versions.
//! It is exchanged during the login/register
//! handshake: clients send [`ClientVersionInfo`] as the body prefix of both
//! login-register request shapes, the server gates on
//! [`is_protocol_compatible`] before touching credentials and advertises
//! its own version in the response. Incompatible clients are rejected with
//! an `EvictionReason::IncompatibleProtocol` frame carrying the accepted
//! range.
//!
//! # Wire spec (language-neutral)
//!
//! Reference for SDKs that do not consume this crate. All integers are
//! little-endian on the wire.
//!
//! ## Packed protocol version
//!
//! A semver `major.minor.patch` packs into one `u32`, 10 bits per
//! component (each must be < 1024):
//!
//! ```text
//! bits 31..30 reserved (zero)
//! bits 29..20 major
//! bits 19..10 minor
//! bits 9..0 patch
//! value = major << 20 | minor << 10 | patch
//! ```
//!
//! Integer order equals semver order. Every component participates in the
//! compatibility check; a patch increment does not imply wire compatibility.
//!
//! ## `ClientVersionInfo` body prefix
//!
//! ```text
//! [protocol_version: u32]
//! [sdk_name_len: u8][sdk_name: UTF-8, 1-255 bytes]
//! [sdk_version_len: u8][sdk_version: UTF-8, 1-255 bytes]
//! ```
//!
//! ## Request framing
//!
//! `ClientVersionInfo` is the leading bytes of the login-register request
//! *body*, which itself rides inside a 256-byte VSR `RequestHeader` (see
//! `consensus::header`): `command` = `Command::Request`, `operation` =
//! `Operation::Register`, client id in `RequestHeader.client`. The client
//! sends no group; the server derives it. A foreign SDK emits that header,
//! then the body starting with this prefix, to reach the gate.
//!
//! ## Login gate
//!
//! The server accepts a client when its packed version is in the inclusive
//! range `IGGY_PROTOCOL_VERSION_MIN..=IGGY_PROTOCOL_VERSION`, including patch.
//! Set both bounds manually for each stable release. Keep them equal unless
//! every released protocol in a wider range is deliberately supported.
//!
//! Compatibility is defined between stable releases. Intermediate development
//! and edge builds may share a protocol number while their layouts change;
//! they require matching client and server builds. Such changes do not require
//! a protocol bump for each commit.
//!
//! ## Rejection frame
//!
//! An incompatible login is answered with a header-only 256-byte
//! `Eviction` frame (`EvictionHeader` in `consensus::header`): `reason`
//! at byte 255 is `IncompatibleProtocol` (14), and the accepted window
//! sits at fixed offsets: `server_protocol_version` (max) at byte 144,
//! `server_protocol_version_min` at byte 148, both packed `u32`.
//!
//! A login body without a decodable `ClientVersionInfo` prefix is
//! rejected with reason `MalformedLogin` (15) instead; the window bytes
//! are zero.
use crate::WireError;
use crate::codec::{WireDecode, WireEncode, read_u32_le};
use crate::primitives::identifier::WireName;
use bytes::{BufMut, BytesMut};
/// Bits per packed semver component (each must be < 1024).
const COMPONENT_BITS: u32 = 10;
const COMPONENT_MAX: u32 = (1 << COMPONENT_BITS) - 1;
const PATCH_MASK: u32 = COMPONENT_MAX;
/// Current development protocol version, independent of package versions.
///
/// Version 0.11.1 requires the session-binding secret in login requests.
/// Finalize this number and the minimum during stable release preparation.
/// Released servers using protocol 0.11.0 accept every 0.11.x patch, so an
/// incompatible release needs a new minor version for those servers to reject it.
pub const IGGY_PROTOCOL_VERSION: u32 = pack_protocol_version(0, 11, 1);
/// Oldest protocol version this build accepts at login.
/// Version 0.11.0 has no session-binding secret and is incompatible.
///
/// Keep this equal to the current protocol unless compatibility with older
/// released protocols has been verified. Decide the range per stable release.
pub const IGGY_PROTOCOL_VERSION_MIN: u32 = pack_protocol_version(0, 11, 1);
const _: () =
assert!(IGGY_PROTOCOL_VERSION_MIN > 0 && IGGY_PROTOCOL_VERSION_MIN <= IGGY_PROTOCOL_VERSION);
/// Range check used by the server-side login gate.
///
/// Packed component order preserves semver ordering, so plain integer
/// comparisons enforce both inclusive bounds, including patch versions.
#[must_use]
pub const fn is_protocol_compatible(client: u32) -> bool {
client >= IGGY_PROTOCOL_VERSION_MIN && client <= IGGY_PROTOCOL_VERSION
}
/// Pack semver components: `major << 20 | minor << 10 | patch`.
///
/// # Panics
/// At compile time (const context) when any component exceeds 1023.
#[must_use]
pub const fn pack_protocol_version(major: u32, minor: u32, patch: u32) -> u32 {
assert!(
major <= COMPONENT_MAX && minor <= COMPONENT_MAX && patch <= COMPONENT_MAX,
"semver component exceeds 10-bit packing range"
);
(major << (2 * COMPONENT_BITS)) | (minor << COMPONENT_BITS) | patch
}
/// Display adapter for a packed protocol version (`major.minor.patch`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolVersion(pub u32);
impl std::fmt::Display for ProtocolVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}.{}.{}",
self.0 >> (2 * COMPONENT_BITS),
(self.0 >> COMPONENT_BITS) & COMPONENT_MAX,
self.0 & PATCH_MASK
)
}
}
/// Client identity sent as the prefix of every login-register request body.
///
/// Wire format:
/// ```text
/// [protocol_version:u32 LE][sdk_name_len:u8][sdk_name:N][sdk_version_len:u8][sdk_version:N]
/// ```
///
/// `protocol_version` is the packed wire version the client implements;
/// `sdk_version` is the client crate's own version
/// (e.g. the `iggy` crate for the Rust SDK). Encoded first so the server can
/// parse and gate on it regardless of how the rest of the body evolves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientVersionInfo {
pub protocol_version: u32,
/// SDK identifier, e.g. `rust-sdk`, `go-sdk`.
pub sdk_name: WireName,
/// SDK build version, e.g. `0.10.1`.
pub sdk_version: WireName,
}
impl WireEncode for ClientVersionInfo {
fn encoded_size(&self) -> usize {
4 + self.sdk_name.encoded_size() + self.sdk_version.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
buf.put_u32_le(self.protocol_version);
self.sdk_name.encode(buf);
self.sdk_version.encode(buf);
}
}
impl WireDecode for ClientVersionInfo {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let protocol_version = read_u32_le(buf, 0)?;
let mut pos = 4;
let (sdk_name, sdk_name_len) = WireName::decode(&buf[pos..])?;
pos += sdk_name_len;
let (sdk_version, sdk_version_len) = WireName::decode(&buf[pos..])?;
pos += sdk_version_len;
Ok((
Self {
protocol_version,
sdk_name,
sdk_version,
},
pos,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ClientVersionInfo {
ClientVersionInfo {
protocol_version: IGGY_PROTOCOL_VERSION,
sdk_name: WireName::new("rust-sdk").unwrap(),
sdk_version: WireName::new("0.10.1").unwrap(),
}
}
#[test]
fn roundtrip() {
let info = sample();
let bytes = info.to_bytes();
let (decoded, consumed) = ClientVersionInfo::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, info);
}
#[test]
fn encoded_size_matches_output() {
let info = sample();
assert_eq!(info.encoded_size(), info.to_bytes().len());
}
#[test]
fn truncated_returns_error() {
let bytes = sample().to_bytes();
for i in 0..bytes.len() {
assert!(
ClientVersionInfo::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
}
#[test]
fn wire_layout_protocol_version_first() {
let bytes = sample().to_bytes();
assert_eq!(
u32::from_le_bytes(bytes[..4].try_into().unwrap()),
IGGY_PROTOCOL_VERSION
);
assert_eq!(bytes[4], 8); // sdk_name len
assert_eq!(&bytes[5..13], b"rust-sdk");
}
#[test]
fn login_without_binding_secret_is_outside_the_compatible_window() {
assert!(!is_protocol_compatible(pack_protocol_version(0, 11, 0)));
assert!(is_protocol_compatible(pack_protocol_version(0, 11, 1)));
assert!(!is_protocol_compatible(pack_protocol_version(0, 11, 1023)));
assert!(!is_protocol_compatible(pack_protocol_version(0, 12, 0)));
}
#[test]
fn packing_preserves_semver_order() {
assert!(pack_protocol_version(0, 9, 999) < pack_protocol_version(0, 10, 0));
assert!(pack_protocol_version(0, 10, 1) < pack_protocol_version(1, 0, 0));
}
#[test]
fn min_requires_the_session_binding_wire_layout() {
assert_eq!(IGGY_PROTOCOL_VERSION_MIN, pack_protocol_version(0, 11, 1));
}
#[test]
fn compatibility_range_boundaries() {
assert!(is_protocol_compatible(IGGY_PROTOCOL_VERSION_MIN));
assert!(is_protocol_compatible(IGGY_PROTOCOL_VERSION));
assert!(
!is_protocol_compatible(IGGY_PROTOCOL_VERSION + 1),
"a newer patch must not pass the current release's protocol gate"
);
assert!(!is_protocol_compatible(IGGY_PROTOCOL_VERSION_MIN - 1));
assert!(!is_protocol_compatible(u32::MAX));
}
#[test]
fn protocol_version_display() {
assert_eq!(
ProtocolVersion(pack_protocol_version(0, 10, 1)).to_string(),
"0.10.1"
);
}
}