Files
telemt/src/web/manager/negotiation.rs
T
2026-08-31 21:39:09 +03:00

290 lines
9.4 KiB
Rust

use std::net::IpAddr;
use crate::config::WebCarrier;
/// Stable client classification used only for carrier negotiation and learning.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum CarrierClientClass {
/// A client that did not present server-bridge negotiation metadata.
Legacy,
/// The generated bridge presented the explicit capability marker.
Bridge,
/// Strict same-origin browser metadata survived while the marker did not.
BrowserHint,
/// A native iOS client classified for diagnostics and learning only.
Ios,
}
impl CarrierClientClass {
/// Returns the non-sensitive token exposed by WEB debug lifecycle records.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Legacy => "legacy",
Self::Bridge => "bridge",
Self::BrowserHint => "browser-hint",
Self::Ios => "ios",
}
}
}
/// Canonical carrier failure category reported by the generated bridge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CarrierFailure {
/// The cumulative attempt deadline elapsed.
Timeout,
/// The browser observed a network failure.
Network,
/// A WebSocket upgrade or post-upgrade acknowledgement failed.
Upgrade,
/// The carrier returned an unexpected HTTP result.
Http,
/// The carrier violated its response or framing contract.
Protocol,
}
impl CarrierFailure {
/// Complete fixed bridge failure set in stable metric order.
pub(crate) const ALL: [Self; 5] = [
Self::Timeout,
Self::Network,
Self::Upgrade,
Self::Http,
Self::Protocol,
];
/// Returns the stable fixed-slot index used by process telemetry.
pub(crate) const fn index(self) -> usize {
match self {
Self::Timeout => 0,
Self::Network => 1,
Self::Upgrade => 2,
Self::Http => 3,
Self::Protocol => 4,
}
}
/// Parses one canonical bridge failure token.
pub(crate) const fn parse(value: &str) -> Option<Self> {
match value.as_bytes() {
b"timeout" => Some(Self::Timeout),
b"network" => Some(Self::Network),
b"upgrade" => Some(Self::Upgrade),
b"http" => Some(Self::Http),
b"protocol" => Some(Self::Protocol),
_ => None,
}
}
/// Returns the canonical non-sensitive failure token.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::Network => "network",
Self::Upgrade => "upgrade",
Self::Http => "http",
Self::Protocol => "protocol",
}
}
}
/// Validated carrier capability set sent by a negotiation-capable client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierCapabilities(u8);
impl CarrierCapabilities {
/// Returns a set containing every carrier implemented by the generated bridge.
pub(crate) const fn all() -> Self {
Self(0b1111)
}
/// Returns the current server-authoritative native iOS capability ceiling.
pub(crate) const fn ios() -> Self {
Self(1 << WebCarrier::Https.index())
}
/// Builds a set from a validated bit representation.
pub(crate) const fn from_bits(bits: u8) -> Option<Self> {
if bits != 0 && bits & !0b1111 == 0 {
Some(Self(bits))
} else {
None
}
}
/// Returns whether the bridge can run one carrier.
pub(crate) const fn contains(self, carrier: WebCarrier) -> bool {
self.0 & (1 << carrier.index()) != 0
}
/// Intersects declared capabilities with an authoritative server ceiling.
pub(crate) const fn intersection(self, ceiling: Self) -> Option<Self> {
Self::from_bits(self.0 & ceiling.0)
}
}
/// Immutable metadata attached to one session-creation attempt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierRequest {
class: CarrierClientClass,
capabilities: Option<CarrierCapabilities>,
attempt: Option<u8>,
failure: Option<CarrierFailure>,
user_agent_hash: [u8; 32],
}
impl CarrierRequest {
/// Constructs legacy metadata without enabling negotiation or learning.
pub(crate) const fn legacy(user_agent_hash: [u8; 32]) -> Self {
Self {
class: CarrierClientClass::Legacy,
capabilities: None,
attempt: None,
failure: None,
user_agent_hash,
}
}
/// Constructs a metadata-free native client without inferring capabilities.
pub(crate) const fn ios(user_agent_hash: [u8; 32]) -> Self {
Self {
class: CarrierClientClass::Ios,
capabilities: None,
attempt: None,
failure: None,
user_agent_hash,
}
}
/// Constructs validated automatic-negotiation metadata.
pub(crate) const fn automatic(
class: CarrierClientClass,
capabilities: CarrierCapabilities,
attempt: u8,
failure: Option<CarrierFailure>,
user_agent_hash: [u8; 32],
) -> Self {
Self {
class,
capabilities: Some(capabilities),
attempt: Some(attempt),
failure,
user_agent_hash,
}
}
/// Returns whether this request participates in server-side negotiation.
pub(crate) const fn is_automatic(self) -> bool {
self.capabilities.is_some()
}
/// Returns whether server capability filtering applies to this request.
pub(crate) const fn uses_capabilities(self) -> bool {
self.capabilities.is_some()
}
/// Returns the canonical attempt number when negotiation is active.
pub(crate) const fn attempt(self) -> Option<u8> {
self.attempt
}
/// Returns the reported reason for advancing from the previous candidate.
pub(crate) const fn failure(self) -> Option<CarrierFailure> {
self.failure
}
/// Returns whether a carrier is supported by this request.
pub(crate) const fn supports(self, carrier: WebCarrier) -> bool {
match self.capabilities {
Some(capabilities) => capabilities.contains(carrier),
None => false,
}
}
/// Returns the stable non-sensitive client class.
pub(crate) const fn class(self) -> CarrierClientClass {
self.class
}
/// Returns the normalized User-Agent digest.
pub(crate) const fn user_agent_hash(self) -> [u8; 32] {
self.user_agent_hash
}
/// Checks the capability identity frozen across sequential attempts.
pub(crate) fn matches_client(self, other: Self) -> bool {
self.class == other.class
&& self.capabilities_bits() == other.capabilities_bits()
&& self.user_agent_hash == other.user_agent_hash
}
/// Checks the complete idempotent identity of one exact attempt request.
pub(crate) fn matches_attempt(self, other: Self) -> bool {
self.matches_client(other) && self.attempt == other.attempt && self.failure == other.failure
}
fn capabilities_bits(self) -> Option<u8> {
self.capabilities.map(|capabilities| capabilities.0)
}
}
/// Returns the cumulative deadline slot assigned to one carrier attempt.
pub(super) const fn carrier_attempt_deadline_index(
candidate_count: u8,
attempt: u8,
) -> Option<usize> {
if candidate_count == 0 || candidate_count > 4 || attempt == 0 || attempt > candidate_count {
return None;
}
if attempt == candidate_count {
Some(3)
} else {
Some((attempt - 1) as usize)
}
}
/// Secret-independent evidence owner frozen into an automatic session.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierLearningContext {
/// Stable profile namespace.
pub(crate) profile_key: super::ProfileKey,
/// Effective client address from the trusted L7 boundary.
pub(crate) client_ip: IpAddr,
/// Client-class namespace for normalized User-Agent evidence.
pub(crate) class: CarrierClientClass,
/// Domain-separated normalized User-Agent digest.
pub(crate) user_agent_hash: [u8; 32],
/// Hot-reload epoch that rejects late outcomes from an older policy.
pub(crate) epoch: u64,
/// Whether the authoritative client address is safe to use as learning evidence.
pub(crate) ip_learning_eligible: bool,
}
#[cfg(test)]
mod tests {
use super::carrier_attempt_deadline_index;
#[test]
fn final_candidate_uses_the_final_cumulative_deadline_slot() {
assert_eq!(carrier_attempt_deadline_index(1, 1), Some(3));
assert_eq!(carrier_attempt_deadline_index(2, 1), Some(0));
assert_eq!(carrier_attempt_deadline_index(2, 2), Some(3));
assert_eq!(carrier_attempt_deadline_index(3, 1), Some(0));
assert_eq!(carrier_attempt_deadline_index(3, 2), Some(1));
assert_eq!(carrier_attempt_deadline_index(3, 3), Some(3));
assert_eq!(carrier_attempt_deadline_index(4, 1), Some(0));
assert_eq!(carrier_attempt_deadline_index(4, 2), Some(1));
assert_eq!(carrier_attempt_deadline_index(4, 3), Some(2));
assert_eq!(carrier_attempt_deadline_index(4, 4), Some(3));
}
#[test]
fn invalid_candidate_or_attempt_counts_have_no_deadline_slot() {
for (candidate_count, attempt) in [(0, 1), (5, 1), (1, 0), (1, 2), (3, 4)] {
assert_eq!(
carrier_attempt_deadline_index(candidate_count, attempt),
None
);
}
}
}