You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by hg...@apache.org on 2026/06/30 07:36:14 UTC
(iggy) branch login-command-protocol-versin updated (49509b2be -> e1a459f14)
This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a change to branch login-command-protocol-versin
in repository https://gitbox.apache.org/repos/asf/iggy.git
omit 49509b2be Merge branch 'master' into login-command-protocol-versin
new e1a459f14 Merge branch 'master' into login-command-protocol-versin
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (49509b2be)
\
N -- N -- N refs/heads/login-command-protocol-versin (e1a459f14)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
core/binary_protocol/src/version.rs | 14 ++++++++++++++
core/common/src/traits/binary_impls/mod.rs | 21 ++++++++++++++++++++-
core/common/src/traits/binary_impls/users.rs | 2 ++
core/server-ng/src/dispatch.rs | 8 +++++---
4 files changed, 41 insertions(+), 4 deletions(-)
(iggy) 01/01: Merge branch 'master' into login-command-protocol-versin
Posted by hg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch login-command-protocol-versin
in repository https://gitbox.apache.org/repos/asf/iggy.git
commit e1a459f1481792c4965c193033646da2ec92dd16
Merge: f200135fc ec3a942c0
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Tue Jun 30 08:03:50 2026 +0200
Merge branch 'master' into login-command-protocol-versin
core/binary_protocol/src/version.rs | 14 ++++++++++++++
core/common/src/traits/binary_impls/mod.rs | 21 ++++++++++++++++++++-
core/common/src/traits/binary_impls/users.rs | 2 ++
core/server-ng/src/dispatch.rs | 8 +++++---
foreign/csharp/Directory.Packages.props | 2 +-
5 files changed, 42 insertions(+), 5 deletions(-)
diff --cc core/binary_protocol/src/version.rs
index fa2c5101c,000000000..1eaba07c5
mode 100644,000000..100644
--- a/core/binary_protocol/src/version.rs
+++ b/core/binary_protocol/src/version.rs
@@@ -1,336 -1,0 +1,350 @@@
+// 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 protocol version is this crate's own semver, const-parsed from
+//! `CARGO_PKG_VERSION` into a packed `u32` so it auto-bumps with releases
+//! and stays cheaply comparable. 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. The value tracks the
+//! `iggy_binary_protocol` crate release; under 0.x a minor bump may break
+//! the wire, so the gate is minor-scoped. Past 1.0.0 the gate follows
+//! strict semver: major bump = incompatible, minor/patch = compatible.
+//!
+//! ## `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` = `Command2::Request`, `operation` =
++//! `Operation::Register`, `namespace` = `METADATA_CONSENSUS_NAMESPACE`,
++//! client id in `RequestHeader.client`. 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
+//! `>= IGGY_PROTOCOL_VERSION_MIN` and its `major.minor` is `<=` the
+//! server's. Patch releases never change the wire, so the upper bound
+//! ignores patch. `IGGY_PROTOCOL_VERSION_MIN` defaults to the current
+//! version with patch zeroed and is widened deliberately when a minor
+//! bump stays wire-compatible.
+//!
+//! ## 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 binary protocol version: this crate's semver, packed.
+/// Pre-release tags (`-edge.N`) are ignored.
+pub const IGGY_PROTOCOL_VERSION: u32 = parse_packed_semver(env!("CARGO_PKG_VERSION"));
+
+/// Oldest protocol version this build still accepts at login: the current
+/// version with patch zeroed (patch releases never change the wire).
+/// Widen deliberately when a minor bump stays wire-compatible.
++///
++/// Under 0.x this makes a server minor bump a client flag-day: every
++/// prior-minor client is rejected at login until MIN is widened. A rolling
++/// upgrade across a minor bump must either widen MIN (when the wire stayed
++/// compatible) or accept that old clients fail re-login -- decide per release.
+// TODO(hubcio): past 1.0.0 follow strict semver: major bump = incompatible,
+// minor/patch = compatible, so MIN derives from the current major instead
+// of the current minor. Under 0.x a minor bump may break the wire, so the
+// minor-scoped window is correct.
+pub const IGGY_PROTOCOL_VERSION_MIN: u32 = IGGY_PROTOCOL_VERSION & !PATCH_MASK;
+// A 0.0.x crate version would pack MIN to 0, which `EvictionHeader::validate`
+// rejects (window requires min >= 1) -- the server would emit eviction frames
+// that fail its own validation.
+const _: () = assert!(IGGY_PROTOCOL_VERSION_MIN > 0);
+
+/// Range check used by the server-side login gate.
+///
+/// Packed component order preserves semver ordering, so plain integer
+/// comparisons work. The upper bound ignores patch: patch releases never
+/// change the wire, so a newer patch of an accepted minor is compatible.
+#[must_use]
+pub const fn is_protocol_compatible(client: u32) -> bool {
+ client >= IGGY_PROTOCOL_VERSION_MIN
+ && (client >> COMPONENT_BITS) <= (IGGY_PROTOCOL_VERSION >> COMPONENT_BITS)
+}
+
+/// 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
+ )
+ }
+}
+
+/// Const-parse `major.minor.patch[-pre]` into a packed `u32`.
+/// Malformed input is a compile error in const context.
+const fn parse_packed_semver(version: &str) -> u32 {
+ let bytes = version.as_bytes();
+ let (major, i) = parse_component(bytes, 0);
+ assert!(
+ i < bytes.len() && bytes[i] == b'.',
+ "expected '.' after major"
+ );
+ let (minor, j) = parse_component(bytes, i + 1);
+ assert!(
+ j < bytes.len() && bytes[j] == b'.',
+ "expected '.' after minor"
+ );
+ let (patch, k) = parse_component(bytes, j + 1);
+ assert!(
+ k == bytes.len() || bytes[k] == b'-' || bytes[k] == b'+',
+ "unexpected trailing bytes after patch"
+ );
+ pack_protocol_version(major, minor, patch)
+}
+
+/// Parse a decimal run starting at `start`; returns (value, index past digits).
+const fn parse_component(bytes: &[u8], start: usize) -> (u32, usize) {
+ assert!(
+ start < bytes.len() && bytes[start].is_ascii_digit(),
+ "expected digit"
+ );
+ let mut value: u32 = 0;
+ let mut i = start;
+ while i < bytes.len() && bytes[i].is_ascii_digit() {
+ value = value * 10 + (bytes[i] - b'0') as u32;
+ i += 1;
+ }
+ (value, i)
+}
+
+/// 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 `iggy_binary_protocol` crate version the
+/// client was built against; `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 parses_crate_version_with_prerelease() {
+ assert_eq!(
+ parse_packed_semver("0.10.1-edge.2"),
+ pack_protocol_version(0, 10, 1)
+ );
+ assert_eq!(parse_packed_semver("1.2.3"), pack_protocol_version(1, 2, 3));
+ assert_eq!(
+ parse_packed_semver("10.0.0+build.5"),
+ pack_protocol_version(10, 0, 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_is_current_with_patch_zeroed() {
+ assert_eq!(IGGY_PROTOCOL_VERSION_MIN & PATCH_MASK, 0);
+ assert_eq!(
+ IGGY_PROTOCOL_VERSION >> COMPONENT_BITS,
+ IGGY_PROTOCOL_VERSION_MIN >> COMPONENT_BITS
+ );
+ }
+
+ #[test]
+ fn compatibility_range_boundaries() {
+ assert!(is_protocol_compatible(IGGY_PROTOCOL_VERSION_MIN));
+ assert!(is_protocol_compatible(IGGY_PROTOCOL_VERSION));
+ // Patch never changes the wire: any patch of the current minor passes.
+ assert!(is_protocol_compatible(IGGY_PROTOCOL_VERSION | PATCH_MASK));
+ // Next minor is outside the window.
+ assert!(!is_protocol_compatible(
+ ((IGGY_PROTOCOL_VERSION >> COMPONENT_BITS) + 1) << COMPONENT_BITS
+ ));
+ if IGGY_PROTOCOL_VERSION_MIN > 0 {
+ assert!(!is_protocol_compatible(IGGY_PROTOCOL_VERSION_MIN - 1));
+ }
+ }
+
+ #[test]
+ fn protocol_version_display() {
+ assert_eq!(
+ ProtocolVersion(pack_protocol_version(0, 10, 1)).to_string(),
+ "0.10.1"
+ );
+ }
+}
diff --cc core/common/src/traits/binary_impls/mod.rs
index 42245cff8,cbdd3edb9..a10112253
--- a/core/common/src/traits/binary_impls/mod.rs
+++ b/core/common/src/traits/binary_impls/mod.rs
@@@ -28,38 -28,8 +28,49 @@@ mod topics
mod users;
use crate::IggyError;
- use crate::http::users::defaults::{MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH};
++use crate::http::users::defaults::{
++ MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
++};
use iggy_binary_protocol::WireDecode;
+#[cfg(feature = "vsr")]
+use iggy_binary_protocol::{ClientVersionInfo, IGGY_PROTOCOL_VERSION, WireName};
+
+/// SDK identifier sent in the login-register version prefix. Foreign SDKs
+/// send their own (e.g. `go-sdk`) once they adopt VSR framing.
+#[cfg(feature = "vsr")]
+pub(crate) const RUST_SDK_NAME: &str = "rust-sdk";
+
+/// Version prefix for both login-register request shapes. `sdk_version`
+/// comes from [`crate::VsrSessionControl::sdk_version`] so it is the SDK
+/// crate's version, not this crate's.
+#[cfg(feature = "vsr")]
+pub(crate) fn rust_sdk_version_info(sdk_version: &str) -> Result<ClientVersionInfo, IggyError> {
+ Ok(ClientVersionInfo {
+ protocol_version: IGGY_PROTOCOL_VERSION,
+ sdk_name: WireName::new(RUST_SDK_NAME).expect("RUST_SDK_NAME is 1-255 bytes"),
+ sdk_version: WireName::new(sdk_version).map_err(|_| IggyError::InvalidFormat)?,
+ })
+}
+
+/// Same bounds and error every server (HTTP and binary) enforces, applied
+/// before encoding so an oversized password can never desync the u8 length
+/// prefix on the wire.
+pub(crate) fn validate_password(password: &str) -> Result<(), IggyError> {
+ if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&password.len()) {
+ return Err(IggyError::InvalidPassword);
+ }
+ Ok(())
+}
+
++/// Same bounds and error the servers enforce for usernames, applied before
++/// encoding so an oversized username can never desync the wire name prefix.
++pub(crate) fn validate_username(username: &str) -> Result<(), IggyError> {
++ if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&username.len()) {
++ return Err(IggyError::InvalidUsername);
++ }
++ Ok(())
++}
+
/// Decode a wire response, logging the error details before converting to `IggyError`.
pub(crate) fn decode_response<T: WireDecode>(response: &[u8]) -> Result<T, IggyError> {
T::decode_from(response).map_err(|e| {
@@@ -67,16 -37,3 +78,24 @@@
IggyError::InvalidFormat
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn validate_password_enforces_server_bounds() {
+ assert!(validate_password(&"a".repeat(MIN_PASSWORD_LENGTH - 1)).is_err());
+ assert!(validate_password(&"a".repeat(MIN_PASSWORD_LENGTH)).is_ok());
+ assert!(validate_password(&"a".repeat(MAX_PASSWORD_LENGTH)).is_ok());
+ assert!(validate_password(&"a".repeat(MAX_PASSWORD_LENGTH + 1)).is_err());
+ }
++
++ #[test]
++ fn validate_username_enforces_server_bounds() {
++ assert!(validate_username(&"a".repeat(MIN_USERNAME_LENGTH - 1)).is_err());
++ assert!(validate_username(&"a".repeat(MIN_USERNAME_LENGTH)).is_ok());
++ assert!(validate_username(&"a".repeat(MAX_USERNAME_LENGTH)).is_ok());
++ assert!(validate_username(&"a".repeat(MAX_USERNAME_LENGTH + 1)).is_err());
++ }
+}
diff --cc core/common/src/traits/binary_impls/users.rs
index 2b9d9fe39,fa7cc6411..37bc907b7
--- a/core/common/src/traits/binary_impls/users.rs
+++ b/core/common/src/traits/binary_impls/users.rs
@@@ -85,7 -85,6 +85,8 @@@ impl<B: BinaryClient> UserClient for B
permissions: Option<Permissions>,
) -> Result<UserInfoDetails, IggyError> {
fail_if_not_authenticated(self).await?;
++ super::validate_username(username)?;
+ super::validate_password(password)?;
let wire_name = WireName::new(username).map_err(|_| IggyError::InvalidFormat)?;
let wire_perms = permissions.as_ref().map(permissions_to_wire);
let response = self
@@@ -184,7 -181,6 +185,8 @@@
}
async fn login_user(&self, username: &str, password: &str) -> Result<IdentityInfo, IggyError> {
++ super::validate_username(username)?;
+ super::validate_password(password)?;
#[cfg(feature = "vsr")]
{
let wire_name = WireName::new(username).map_err(|_| IggyError::InvalidFormat)?;
diff --cc core/server-ng/src/dispatch.rs
index 70859bd84,245663b49..b33870193
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@@ -1574,47 -1570,7 +1574,49 @@@ async fn handle_login_register_request
let body = request_body(&request);
let vsr_client_id = request.header().client;
- if let Ok(wire_request) = LoginRegisterRequest::decode_from(body) {
+ // Both login-register shapes share the ClientVersionInfo prefix, so the
+ // protocol gate decodes it once and runs before any credential work; the
- // body shapes below parse from past the prefix. Clients from before the
- // header relayout never reach this gate: their command byte sits at the
- // old offset, so the typed-header decode drops the frame earlier.
++ // body shapes below parse from past the prefix. Only VSR clients reach
++ // this gate -- legacy SDKs use LOGIN_USER_CODE, a separate path. A
++ // pre-versioning VSR client sends the old prefix-less body, which fails
++ // ClientVersionInfo::decode (-> MalformedLogin) or the version gate
++ // (-> IncompatibleProtocol) right here, not dropped earlier.
+ let Ok((version_info, prefix_len)) = ClientVersionInfo::decode(body) else {
+ warn!(
+ transport_client_id,
+ "rejecting login: body has no decodable version prefix"
+ );
+ send_login_eviction(
+ shard,
+ transport_client_id,
+ vsr_client_id,
+ EvictionReason::MalformedLogin,
+ )
+ .await;
+ return;
+ };
+ if !is_protocol_compatible(version_info.protocol_version) {
+ warn!(
+ transport_client_id,
+ client_protocol_version = %ProtocolVersion(version_info.protocol_version),
+ sdk_name = %version_info.sdk_name,
+ sdk_version = %version_info.sdk_version,
+ "rejecting login: incompatible protocol version"
+ );
+ send_login_eviction(
+ shard,
+ transport_client_id,
+ vsr_client_id,
+ EvictionReason::IncompatibleProtocol,
+ )
+ .await;
+ return;
+ }
+
+ let body_tail = &body[prefix_len..];
+ if let Ok((wire_request, _)) =
+ LoginRegisterRequest::decode_after_prefix(version_info.clone(), body_tail)
+ {
match verify_login_credentials(
shard,
wire_request.username.as_str(),