blob: 0ab8d96f4afda42621edac9fc72d6c4470ab3928 [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.
//! A logical session keeps its identity, bind secret, and request counter
//! across transport reconnects. Registration retries recover the original
//! committed session. Explicit logout starts a new logical session.
//!
//! # Lifecycle
//!
//! Create one session per logical login. Registration preserves its client
//! identity and secret when retried. `bind` accepts only a
//! nonzero epoch matching any previous binding. Application request IDs are
//! available only after binding; exhaustion fails instead of wrapping.
//! Both `bind` and `next_request_id` return `Result` and callers must handle
//! failure. Explicit disconnect clears remembered sign-in; configured AutoLogin
//! still authenticates the next connection.
use iggy_binary_protocol::requests::users::login_register::BindSecret;
use iggy_common::IggyError;
/// Consensus-level session state.
///
/// Single-threaded: owned by whatever drives the request loop (connection
/// handler or SDK transport). All methods take `&mut self`. If shared
/// access is needed, wrap in `Arc<Mutex<_>>` at the call site.
#[derive(Debug)]
pub struct ConsensusSession {
/// Ephemeral random client identifier. Generated once per process,
/// never persisted. Each SDK instance gets a unique value.
client_id: u128,
/// Session number assigned by the server after register commits
/// through consensus. `None` until bound.
session: Option<u64>,
/// Monotonically increasing request counter for application requests.
/// Starts at 1 after registration. Register itself always uses request=0.
request_counter: u64,
bind_secret: BindSecret,
}
impl ConsensusSession {
/// Create a new session with a random `client_id`.
#[must_use]
pub fn new() -> Self {
Self::with_client_id(generate_client_id())
}
/// Create a session with a specific `client_id` (for testing).
#[must_use]
pub fn with_client_id(client_id: u128) -> Self {
Self {
client_id,
session: None,
request_counter: 1,
bind_secret: BindSecret::new(Box::new(rand::random())),
}
}
/// The ephemeral client identifier for this session.
#[must_use]
pub fn client_id(&self) -> u128 {
self.client_id
}
/// The session number, if registered.
#[must_use]
pub fn session(&self) -> Option<u64> {
self.session
}
/// Whether the session is bound (register committed).
#[must_use]
pub fn is_bound(&self) -> bool {
self.session.is_some()
}
/// Internal bearer proof retained across reconnects. Never log or expose it.
#[doc(hidden)]
pub fn bind_secret(&self) -> BindSecret {
self.bind_secret.clone()
}
/// Accept the original registration, including its replay after reconnect.
pub fn bind(&mut self, session: u64) -> Result<(), IggyError> {
if session == 0 {
return Err(IggyError::InvalidSession(session));
}
if let Some(bound) = self.session
&& bound != session
{
return Err(IggyError::SessionMismatch(bound, session));
}
self.session = Some(session);
Ok(())
}
/// Returns the request ID for the register operation (always 0).
///
pub const fn register_request_id(&self) -> u64 {
0
}
/// Get the next application request ID and advance the counter.
///
/// Returns 1, 2, 3, ... (request 0 is reserved for register).
///
pub fn next_request_id(&mut self) -> Result<u64, IggyError> {
if !self.is_bound() {
return Err(IggyError::Unauthenticated);
}
let id = self.request_counter;
self.request_counter = self
.request_counter
.checked_add(1)
.ok_or(IggyError::RequestIdExhausted)?;
Ok(id)
}
/// Current request counter value (the next ID that will be returned).
#[must_use]
pub fn current_request_id(&self) -> u64 {
self.request_counter
}
}
impl Default for ConsensusSession {
fn default() -> Self {
Self::new()
}
}
/// Generate an ephemeral random u128 client ID using UUID v4.
///
/// Non-zero by construction (UUID v4 has fixed bits that prevent all-zeros).
fn generate_client_id() -> u128 {
iggy_common::random_id::get_uuid()
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
#[test]
fn new_session_is_unbound_and_has_unique_credentials() {
let first = ConsensusSession::new();
let second = ConsensusSession::new();
assert!(!first.is_bound());
assert_ne!(first.client_id(), 0);
assert_ne!(first.client_id(), second.client_id());
assert_ne!(
first.bind_secret().expose_secret(),
second.bind_secret().expose_secret()
);
}
#[test]
fn lost_registration_reply_preserves_identity_and_secret() {
let session = ConsensusSession::with_client_id(7);
let secret = session.bind_secret();
assert_eq!(session.register_request_id(), 0);
assert_eq!(session.register_request_id(), 0);
assert_eq!(session.client_id(), 7);
assert_eq!(
session.bind_secret().expose_secret(),
secret.expose_secret()
);
assert!(!session.is_bound());
}
#[test]
fn reconnect_preserves_binding_and_request_counter() {
let mut session = ConsensusSession::with_client_id(7);
session.bind(42).unwrap();
assert_eq!(session.next_request_id().unwrap(), 1);
assert_eq!(session.register_request_id(), 0);
session.bind(42).unwrap();
assert_eq!(session.client_id(), 7);
assert_eq!(session.session(), Some(42));
assert_eq!(session.next_request_id().unwrap(), 2);
}
#[test]
fn conflicting_registration_cannot_replace_a_live_session() {
let mut session = ConsensusSession::with_client_id(7);
assert_eq!(session.bind(0), Err(IggyError::InvalidSession(0)));
session.bind(42).unwrap();
assert!(matches!(
session.bind(43),
Err(IggyError::SessionMismatch(42, 43))
));
assert_eq!(session.session(), Some(42));
}
#[test]
fn unbound_session_cannot_number_a_mutation() {
let mut session = ConsensusSession::with_client_id(7);
assert_eq!(session.next_request_id(), Err(IggyError::Unauthenticated));
assert_eq!(session.current_request_id(), 1);
}
#[test]
fn exhausted_counter_cannot_wrap_or_issue_another_request() {
let mut session = ConsensusSession::with_client_id(7);
session.bind(42).unwrap();
session.request_counter = u64::MAX;
assert_eq!(
session.next_request_id(),
Err(IggyError::RequestIdExhausted)
);
assert_eq!(session.current_request_id(), u64::MAX);
}
}