From ad333d64b0995fefa027eb027ad3b9113b796f1a Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:39:26 +0100 Subject: [PATCH 01/10] client version checking 1 --- .../src/enterprise/grpc/desktop_client_mfa.rs | 4 +- .../src/enterprise/grpc/polling.rs | 11 +- crates/defguard_core/src/grpc/client_mfa.rs | 4 +- .../defguard_core/src/grpc/client_version.rs | 217 ++++++++++++++++++ crates/defguard_core/src/grpc/enrollment.rs | 24 +- crates/defguard_core/src/grpc/mod.rs | 13 +- .../defguard_core/src/grpc/password_reset.rs | 8 +- crates/defguard_core/src/grpc/utils.rs | 25 +- 8 files changed, 282 insertions(+), 24 deletions(-) create mode 100644 crates/defguard_core/src/grpc/client_version.rs diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs index b03d904df..6e5e8d032 100644 --- a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -11,7 +11,7 @@ use crate::{ events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, grpc::{ client_mfa::{ClientLoginSession, ClientMfaServer}, - utils::parse_client_info, + utils::parse_client_ip_agent, }, }; @@ -66,7 +66,7 @@ impl ClientMfaServer { return Err(Status::invalid_argument("invalid MFA method")); } - let (ip, _user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new( user.id, user.username.clone(), diff --git a/crates/defguard_core/src/enterprise/grpc/polling.rs b/crates/defguard_core/src/enterprise/grpc/polling.rs index ecce3ec2f..8e04e9a41 100644 --- a/crates/defguard_core/src/enterprise/grpc/polling.rs +++ b/crates/defguard_core/src/enterprise/grpc/polling.rs @@ -1,5 +1,5 @@ use defguard_common::db::Id; -use defguard_proto::proxy::{InstanceInfoRequest, InstanceInfoResponse}; +use defguard_proto::proxy::{DeviceInfo, InstanceInfoRequest, InstanceInfoResponse}; use sqlx::PgPool; use tonic::Status; @@ -47,7 +47,11 @@ impl PollingServer { /// Prepares instance info for polling requests. Enterprise only. #[instrument(skip_all)] - pub async fn info(&self, request: InstanceInfoRequest) -> Result { + pub async fn info( + &self, + request: InstanceInfoRequest, + device_info: Option, + ) -> Result { trace!("Polling info start"); let token = self.validate_session(&request.token).await?; let Some(device) = Device::find_by_id(&self.pool, token.device_id) @@ -82,7 +86,8 @@ impl PollingServer { } // Build and return polling info. - let device_config = build_device_config_response(&self.pool, device, None).await?; + let device_config = + build_device_config_response(&self.pool, device, None, device_info).await?; Ok(InstanceInfoResponse { device_config: Some(device_config), diff --git a/crates/defguard_core/src/grpc/client_mfa.rs b/crates/defguard_core/src/grpc/client_mfa.rs index ea1e6482d..f688a41a4 100644 --- a/crates/defguard_core/src/grpc/client_mfa.rs +++ b/crates/defguard_core/src/grpc/client_mfa.rs @@ -32,7 +32,7 @@ use crate::{ }, enterprise::{db::models::openid_provider::OpenIdProvider, is_enterprise_enabled}, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, - grpc::utils::parse_client_info, + grpc::utils::parse_client_ip_agent, handlers::mail::send_email_mfa_code_email, }; @@ -395,7 +395,7 @@ impl ClientMfaServer { } = session; // Prepare event context - let (ip, _user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new( user.id, user.username.clone(), diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs new file mode 100644 index 000000000..463793f9c --- /dev/null +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -0,0 +1,217 @@ +use defguard_proto::proxy::DeviceInfo; +use semver::Version; + +pub(crate) struct ClientPlatform { + /// The general OS family, e.g., "windows", "macos", "linux" + os_family: String, + /// Specific OS type, e.g., "Ubuntu", "Debian" + /// May sometimes be the same as `os_family`, e.g., "Windows" + #[allow(dead_code)] + os_type: String, + #[allow(dead_code)] + version: String, + #[allow(dead_code)] + architecture: Option, + #[allow(dead_code)] + edition: Option, + #[allow(dead_code)] + codename: Option, + /// "32-bit", "64-bit" + #[allow(dead_code)] + biteness: Option, +} + +impl TryFrom<&str> for ClientPlatform { + type Error = String; + + fn try_from(value: &str) -> Result { + let parts: Vec<&str> = value.split(';').collect(); + let mut os_family = None; + let mut os_type = None; + let mut version = None; + let mut architecture = None; + let mut edition = None; + let mut codename = None; + let mut biteness = None; + // The expected format is: + // "os_family={}; os_type={}; version={}; edition={}; codename={}; bitness={}; architecture={}", + for part in parts { + let kv: Vec<&str> = part.trim().splitn(2, '=').collect(); + if kv.len() != 2 { + continue; + } + match kv[0].trim() { + "os_family" => { + let trimmed = kv[1].trim(); + os_family = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + "os_type" => { + let trimmed = kv[1].trim(); + os_type = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + "version" => { + let trimmed = kv[1].trim(); + version = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + "architecture" => { + let trimmed = kv[1].trim(); + architecture = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + "edition" => { + let trimmed = kv[1].trim(); + edition = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + "codename" => { + let trimmed = kv[1].trim(); + codename = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + "bitness" => { + let trimmed = kv[1].trim(); + biteness = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + _ => {} + } + } + + let (Some(os_family), Some(os_type), Some(version)) = (os_family, os_type, version) else { + let msg = format!( + "invalid client platform string: {value}. OS family, its concrete type and version are required." + ); + error!(msg); + return Err(msg); + }; + + Ok(Self { + os_family, + os_type, + version, + architecture, + edition, + codename, + biteness, + }) + } +} + +pub(crate) fn parse_client_version_platform( + info: Option<&DeviceInfo>, +) -> (Option, Option) { + let Some(info) = info else { + debug!("Device information is missing from the request"); + return (None, None); + }; + + let version = info.version.as_ref().map_or_else( + || None, + |v| { + Version::parse(v).map_or_else( + |_| { + error!("Invalid version string: {}", v); + None + }, + Some, + ) + }, + ); + + let platform = info.platform.as_ref().and_then(|p| { + ClientPlatform::try_from(p.as_str()).map_or_else( + |_| { + error!("Invalid platform string: {}", p); + None + }, + Some, + ) + }); + + (version, platform) +} + +/// Represents a client feature that may have minimum version and OS family requirements. +#[derive(Debug)] +pub(crate) enum ClientFeature { + ServiceLocations, +} + +impl ClientFeature { + const fn min_version(&self) -> Option { + match self { + Self::ServiceLocations => Some(Version::new(1, 6, 0)), + } + } + + fn required_os_family(&self) -> Option> { + match self { + Self::ServiceLocations => Some(vec!["windows"]), + } + } + + pub(crate) fn is_supported_by_device(&self, info: Option<&DeviceInfo>) -> bool { + let (version, platform) = parse_client_version_platform(info); + + // No minimum version = matches all + let version_matches = self.min_version().is_none_or(|min_version| { + // No version info = does not match + version + .as_ref() + .is_some_and(|version| version >= &min_version) + }); + + if !version_matches { + debug!( + "Client version {:?} does not meet minimum version {:?} for feature {:?}", + version, + self.min_version(), + self + ); + } + + // No required OS family = matches all + let platform_matches = self.required_os_family().is_none_or(|platforms| { + platforms.iter().any(|p| { + platform + .as_ref() + .is_some_and(|platform| platform.os_family.eq_ignore_ascii_case(p)) + }) + }); + + if !platform_matches { + debug!( + "Client OS {:?} does not meet required OS {:?} for feature {:?}", + platform.as_ref().map(|p| &p.os_family), + self.required_os_family(), + self + ); + } + + version_matches && platform_matches + } +} diff --git a/crates/defguard_core/src/grpc/enrollment.rs b/crates/defguard_core/src/grpc/enrollment.rs index 639ee3cf7..c7a43069b 100644 --- a/crates/defguard_core/src/grpc/enrollment.rs +++ b/crates/defguard_core/src/grpc/enrollment.rs @@ -43,7 +43,10 @@ use crate::{ limits::update_counts, }, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, EnrollmentEvent}, - grpc::utils::{build_device_config_response, new_polling_token, parse_client_info}, + grpc::{ + client_version::ClientFeature, + utils::{build_device_config_response, new_polling_token, parse_client_ip_agent}, + }, handlers::{ mail::{ send_email_mfa_activation_email, send_mfa_configured_email, send_new_device_added_email, @@ -289,7 +292,7 @@ impl EnrollmentServer { })?; // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new(user_id, username, ip, user_agent); self.emit_event(context, EnrollmentEvent::EnrollmentStarted) .map_err(|err| { @@ -479,7 +482,7 @@ impl EnrollmentServer { info!("User {} activated", user.username); // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username.clone(), ip, user_agent); self.emit_event(context, EnrollmentEvent::EnrollmentCompleted) .map_err(|err| { @@ -806,6 +809,16 @@ impl EnrollmentServer { Status::internal("unexpected error") })?; + // Don't send them service locations if they don't support it + let configs = configs + .into_iter() + .filter(|config| { + config.service_location_mode == ServiceLocationMode::Disabled + || ClientFeature::ServiceLocations + .is_supported_by_device(req_device_info.as_ref()) + }) + .collect::>(); + let template_locations: Vec = configs .iter() .map(|c| TemplateLocation { @@ -854,7 +867,7 @@ impl EnrollmentServer { }; // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username.clone(), ip, user_agent); self.emit_event(context, EnrollmentEvent::EnrollmentDeviceAdded { device }) .map_err(|err| { @@ -870,6 +883,7 @@ impl EnrollmentServer { pub async fn get_network_info( &self, request: ExistingDevice, + device_info: Option, ) -> Result { debug!("Getting network info for device: {:?}", request.pubkey); let token = self.validate_session(request.token.as_ref()).await?; @@ -896,7 +910,7 @@ impl EnrollmentServer { } let token = new_polling_token(&self.pool, &device).await?; - build_device_config_response(&self.pool, device, Some(token)).await + build_device_config_response(&self.pool, device, Some(token), device_info).await } // TODO: Add events diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index 85cf800d9..96dbd24e6 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -69,6 +69,7 @@ static VERSION_ZERO: Version = Version::new(0, 0, 0); mod auth; pub(crate) mod client_mfa; +pub mod client_version; pub mod enrollment; pub mod gateway; mod interceptor; @@ -234,7 +235,11 @@ async fn handle_proxy_message_loop( } // rpc GetNetworkInfo (ExistingDevice) returns (DeviceConfigResponse) Some(core_request::Payload::ExistingDevice(request)) => { - match context.enrollment_server.get_network_info(request).await { + match context + .enrollment_server + .get_network_info(request, received.device_info) + .await + { Ok(response_payload) => { Some(core_response::Payload::DeviceConfig(response_payload)) } @@ -345,7 +350,11 @@ async fn handle_proxy_message_loop( } // rpc LocationInfo (LocationInfoRequest) returns (LocationInfoResponse) Some(core_request::Payload::InstanceInfo(request)) => { - match context.polling_server.info(request).await { + match context + .polling_server + .info(request, received.device_info) + .await + { Ok(response_payload) => { Some(core_response::Payload::InstanceInfo(response_payload)) } diff --git a/crates/defguard_core/src/grpc/password_reset.rs b/crates/defguard_core/src/grpc/password_reset.rs index 0c1957e71..4e8b35e6d 100644 --- a/crates/defguard_core/src/grpc/password_reset.rs +++ b/crates/defguard_core/src/grpc/password_reset.rs @@ -14,7 +14,7 @@ use crate::{ }, enterprise::ldap::utils::ldap_change_password, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, PasswordResetEvent}, - grpc::utils::parse_client_info, + grpc::utils::parse_client_ip_agent, handlers::{ mail::{send_password_reset_email, send_password_reset_success_email}, user::check_password_strength, @@ -169,7 +169,7 @@ impl PasswordResetServer { ); // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username, ip, user_agent); self.emit_event(context, PasswordResetEvent::PasswordResetRequested) .map_err(|err| { @@ -235,7 +235,7 @@ impl PasswordResetServer { user.username ); // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username, ip, user_agent); self.emit_event(context, PasswordResetEvent::PasswordResetStarted) .map_err(|err| { @@ -308,7 +308,7 @@ impl PasswordResetServer { )?; // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username, ip, user_agent); self.emit_event(context, PasswordResetEvent::PasswordResetCompleted) .map_err(|err| { diff --git a/crates/defguard_core/src/grpc/utils.rs b/crates/defguard_core/src/grpc/utils.rs index 1f7ffec69..b82e91fa1 100644 --- a/crates/defguard_core/src/grpc/utils.rs +++ b/crates/defguard_core/src/grpc/utils.rs @@ -24,6 +24,7 @@ use crate::{ enterprise::db::models::{ enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider, }, + grpc::client_version::ClientFeature, }; // Create a new token for configuration polling. @@ -72,6 +73,7 @@ pub(crate) async fn build_device_config_response( pool: &PgPool, device: Device, token: Option, + device_info: Option, ) -> Result { let settings = Settings::get_current_settings(); @@ -122,15 +124,17 @@ pub(crate) async fn build_device_config_response( ); Status::internal(format!("unexpected error: {err}")) })?; - if network.should_prevent_service_location_usage() { + + if network.service_location_mode != ServiceLocationMode::Disabled { error!( - "Tried to use service location {} with disabled enterprise features.", - network.name + "Network device {} tried to fetch config for service location {}, which is unsupported.", + device.name, network.name ); return Err(Status::permission_denied( - "service location mode is not available", + "service location mode is not available for network devices", )); } + // DEPRECATED(1.5): superseeded by location_mfa_mode let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; let config = @@ -182,6 +186,15 @@ pub(crate) async fn build_device_config_response( ); continue; } + if network.service_location_mode != ServiceLocationMode::Disabled + && !ClientFeature::ServiceLocations.is_supported_by_device(device_info.as_ref()) + { + info!( + "Device {} does not support service locations feature, skipping sending network {} configuration to device {}.", + device.name, network.name, device.name + ); + continue; + } // DEPRECATED(1.5): superseeded by location_mfa_mode let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; if let Some(wireguard_network_device) = wireguard_network_device { @@ -218,7 +231,7 @@ pub(crate) async fn build_device_config_response( info!( "User {}({}) device {}({}) automatically fetched the newest configuration.", - user.username, user.id, device.name, device.id, + user.username, user.id, device.name, device.id ); Ok(DeviceConfigResponse { @@ -238,7 +251,7 @@ pub(crate) async fn build_device_config_response( } /// Parses `DeviceInfo` returning client IP address and user agent. -pub(crate) fn parse_client_info(info: &Option) -> Result<(IpAddr, String), String> { +pub(crate) fn parse_client_ip_agent(info: &Option) -> Result<(IpAddr, String), String> { let Some(info) = info else { error!("Missing DeviceInfo in proxy request"); return Err("missing device info".to_string()); From 1f91acb7db8951f9b475f260763b8b64beb66caf Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:51:26 +0100 Subject: [PATCH 02/10] Apply suggestions from code review Co-authored-by: Adam --- crates/defguard_core/src/grpc/client_version.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index 463793f9c..1c25696cf 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -134,7 +134,7 @@ pub(crate) fn parse_client_version_platform( |v| { Version::parse(v).map_or_else( |_| { - error!("Invalid version string: {}", v); + error!("Invalid version string: {v}"); None }, Some, @@ -145,7 +145,7 @@ pub(crate) fn parse_client_version_platform( let platform = info.platform.as_ref().and_then(|p| { ClientPlatform::try_from(p.as_str()).map_or_else( |_| { - error!("Invalid platform string: {}", p); + error!("Invalid platform string: {p}"); None }, Some, From fb7fee4d584876d1bf5804c6734cf0b79d54fc9f Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:52:28 +0100 Subject: [PATCH 03/10] Update client_version.rs --- crates/defguard_core/src/grpc/client_version.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index 1c25696cf..516092b99 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -205,10 +205,9 @@ impl ClientFeature { if !platform_matches { debug!( - "Client OS {:?} does not meet required OS {:?} for feature {:?}", + "Client OS {:?} does not meet required OS {:?} for feature {self:?}", platform.as_ref().map(|p| &p.os_family), - self.required_os_family(), - self + self.required_os_family() ); } From 07eb754d01494fc35c72a51ea95a8cbd3db935e8 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:46:18 +0100 Subject: [PATCH 04/10] tests --- .../defguard_core/src/grpc/client_version.rs | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index 516092b99..8e98077ef 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -1,6 +1,7 @@ use defguard_proto::proxy::DeviceInfo; use semver::Version; +#[derive(Debug)] pub(crate) struct ClientPlatform { /// The general OS family, e.g., "windows", "macos", "linux" os_family: String, @@ -214,3 +215,290 @@ impl ClientFeature { version_matches && platform_matches } } + +#[cfg(test)] +mod tests { + use super::*; + + // Helper function to create DeviceInfo + fn create_device_info(version: Option, platform: Option) -> DeviceInfo { + DeviceInfo { + version, + platform, + ..Default::default() + } + } + + #[test] + fn test_client_platform_try_from() { + // Test valid full platform string + let platform_str = "os_family=linux; os_type=Ubuntu; version=22.04; architecture=x86_64; edition=Desktop; codename=jammy; bitness=64-bit"; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_ok()); + let platform = result.unwrap(); + assert_eq!(platform.os_family, "linux"); + assert_eq!(platform.os_type, "Ubuntu"); + assert_eq!(platform.version, "22.04"); + assert_eq!(platform.architecture, Some("x86_64".to_string())); + assert_eq!(platform.edition, Some("Desktop".to_string())); + assert_eq!(platform.codename, Some("jammy".to_string())); + assert_eq!(platform.biteness, Some("64-bit".to_string())); + + // Test minimal valid platform string (only required fields) + let platform_str = "os_family=windows; os_type=Windows; version=11"; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_ok()); + let platform = result.unwrap(); + assert_eq!(platform.os_family, "windows"); + assert_eq!(platform.os_type, "Windows"); + assert_eq!(platform.version, "11"); + assert_eq!(platform.architecture, None); + + // Test with empty optional fields + let platform_str = "os_family=macos; os_type=macOS; version=14.0; architecture=; edition=; codename=; bitness="; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_ok()); + let platform = result.unwrap(); + assert_eq!(platform.os_family, "macos"); + assert_eq!(platform.architecture, None); + assert_eq!(platform.edition, None); + + // Test missing required field (os_family) + let platform_str = "os_type=Ubuntu; version=22.04"; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("OS family")); + + // Test missing required field (os_type) + let platform_str = "os_family=linux; version=22.04"; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_err()); + + // Test missing required field (version) + let platform_str = "os_family=linux; os_type=Ubuntu"; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_err()); + + // Test with extra whitespace + let platform_str = " os_family = linux ; os_type = Ubuntu ; version = 22.04 "; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_ok()); + let platform = result.unwrap(); + assert_eq!(platform.os_family, "linux"); + assert_eq!(platform.os_type, "Ubuntu"); + assert_eq!(platform.version, "22.04"); + + // Test with unknown keys (should be ignored) + let platform_str = "os_family=linux; os_type=Ubuntu; version=22.04; unknown_key=value"; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_ok()); + + // Test with malformed key-value pairs (missing equals sign) + let platform_str = "os_family=linux; os_type=Ubuntu; version=22.04; malformed_field"; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_ok()); + + // Test empty string + let platform_str = ""; + let result = ClientPlatform::try_from(platform_str); + assert!(result.is_err()); + } + + #[test] + fn test_parse_client_version_platform() { + // Test with valid version and platform + let info = create_device_info( + Some("1.5.0".to_string()), + Some("os_family=windows; os_type=Windows; version=11".to_string()), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_some()); + assert_eq!(version.unwrap(), Version::new(1, 5, 0)); + assert!(platform.is_some()); + assert_eq!(platform.unwrap().os_family, "windows"); + + // Test with no DeviceInfo + let (version, platform) = parse_client_version_platform(None); + assert!(version.is_none()); + assert!(platform.is_none()); + + // Test with invalid version string + let info = create_device_info( + Some("invalid.version".to_string()), + Some("os_family=linux; os_type=Ubuntu; version=22.04".to_string()), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_none()); + assert!(platform.is_some()); + + // Test with invalid platform string + let info = create_device_info(Some("1.5.0".to_string()), Some("invalid".to_string())); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_some()); + assert!(platform.is_none()); + + // Test with missing version field + let info = create_device_info( + None, + Some("os_family=linux; os_type=Ubuntu; version=22.04".to_string()), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_none()); + assert!(platform.is_some()); + + // Test with missing platform field + let info = create_device_info(Some("1.5.0".to_string()), None); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_some()); + assert!(platform.is_none()); + + // Test with both fields missing + let info = create_device_info(None, None); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_none()); + assert!(platform.is_none()); + + // Test with pre-release version + let info = create_device_info( + Some("1.5.0-alpha1".to_string()), + Some("os_family=macos; os_type=macOS; version=14.0".to_string()), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_some()); + assert_eq!(version.unwrap(), Version::parse("1.5.0-alpha1").unwrap()); + assert!(platform.is_some()); + } + + #[test] + fn test_client_feature_is_supported_by_device() { + // Test ServiceLocations feature with supported version and OS + let info = create_device_info( + Some("1.6.0".to_string()), + Some("os_family=windows; os_type=Windows; version=11".to_string()), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported on Windows with version 1.6.0" + ); + + // Test with exact minimum version + let info = create_device_info( + Some("1.6.0".to_string()), + Some("os_family=Windows; os_type=Windows; version=11".to_string()), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported at minimum version" + ); + + // Test with higher version + let info = create_device_info( + Some("2.0.0".to_string()), + Some("os_family=WINDOWS; os_type=Windows; version=11".to_string()), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported with higher version" + ); + + // Test with version below minimum + let info = create_device_info( + Some("1.5.9".to_string()), + Some("os_family=windows; os_type=Windows; version=11".to_string()), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported below minimum version" + ); + + // Test with wrong OS family (linux) + let info = create_device_info( + Some("1.6.0".to_string()), + Some("os_family=linux; os_type=Ubuntu; version=22.04".to_string()), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported on Linux" + ); + + // Test with wrong OS family (macos) + let info = create_device_info( + Some("1.6.0".to_string()), + Some("os_family=macos; os_type=macOS; version=14.0".to_string()), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported on macOS" + ); + + // Test with no DeviceInfo + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(None), + "ServiceLocations should not be supported without device info" + ); + + // Test with missing version + let info = create_device_info( + None, + Some("os_family=windows; os_type=Windows; version=11".to_string()), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported without version info" + ); + + // Test with missing platform + let info = create_device_info(Some("1.6.0".to_string()), None); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported without platform info" + ); + + // Test with invalid version string + let info = create_device_info( + Some("invalid".to_string()), + Some("os_family=windows; os_type=Windows; version=11".to_string()), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported with invalid version" + ); + + // Test with invalid platform string + let info = create_device_info(Some("1.6.0".to_string()), Some("invalid".to_string())); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported with invalid platform" + ); + + // Test case insensitivity of OS family matching + let info = create_device_info( + Some("1.6.0".to_string()), + Some("os_family=WiNdOwS; os_type=Windows; version=11".to_string()), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported with mixed-case OS family" + ); + + // Test with pre-release version above minimum + let info = create_device_info( + Some("1.7.0-alpha1".to_string()), + Some("os_family=windows; os_type=Windows; version=11".to_string()), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported with pre-release version above minimum" + ); + + // Test with pre-release version below minimum + let info = create_device_info( + Some("1.5.0-alpha1".to_string()), + Some("os_family=windows; os_type=Windows; version=11".to_string()), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported with pre-release version below minimum" + ); + } +} From d6bedb465445c27f4e92ef25aa7da03570da6c25 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:08:42 +0100 Subject: [PATCH 05/10] fix --- .../defguard_core/src/grpc/client_version.rs | 69 ++++++------------- 1 file changed, 22 insertions(+), 47 deletions(-) diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index 8e98077ef..27ce8602c 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -19,7 +19,7 @@ pub(crate) struct ClientPlatform { codename: Option, /// "32-bit", "64-bit" #[allow(dead_code)] - biteness: Option, + bitness: Option, } impl TryFrom<&str> for ClientPlatform { @@ -33,7 +33,17 @@ impl TryFrom<&str> for ClientPlatform { let mut architecture = None; let mut edition = None; let mut codename = None; - let mut biteness = None; + let mut bitness = None; + + let to_option: fn(&str) -> Option = |s: &str| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }; + // The expected format is: // "os_family={}; os_type={}; version={}; edition={}; codename={}; bitness={}; architecture={}", for part in parts { @@ -41,62 +51,27 @@ impl TryFrom<&str> for ClientPlatform { if kv.len() != 2 { continue; } - match kv[0].trim() { + match kv[0].trim().to_lowercase().as_str() { "os_family" => { - let trimmed = kv[1].trim(); - os_family = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; + os_family = to_option(kv[1]); } "os_type" => { - let trimmed = kv[1].trim(); - os_type = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; + os_type = to_option(kv[1]); } "version" => { - let trimmed = kv[1].trim(); - version = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; + version = to_option(kv[1]); } "architecture" => { - let trimmed = kv[1].trim(); - architecture = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; + architecture = to_option(kv[1]); } "edition" => { - let trimmed = kv[1].trim(); - edition = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; + edition = to_option(kv[1]); } "codename" => { - let trimmed = kv[1].trim(); - codename = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; + codename = to_option(kv[1]); } "bitness" => { - let trimmed = kv[1].trim(); - biteness = if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - }; + bitness = to_option(kv[1]); } _ => {} } @@ -117,7 +92,7 @@ impl TryFrom<&str> for ClientPlatform { architecture, edition, codename, - biteness, + bitness, }) } } @@ -242,7 +217,7 @@ mod tests { assert_eq!(platform.architecture, Some("x86_64".to_string())); assert_eq!(platform.edition, Some("Desktop".to_string())); assert_eq!(platform.codename, Some("jammy".to_string())); - assert_eq!(platform.biteness, Some("64-bit".to_string())); + assert_eq!(platform.bitness, Some("64-bit".to_string())); // Test minimal valid platform string (only required fields) let platform_str = "os_family=windows; os_type=Windows; version=11"; From d31b026c61056161034d15bd66aa17c25dd216f4 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:14:12 +0100 Subject: [PATCH 06/10] update min version --- crates/defguard_core/src/version.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/defguard_core/src/version.rs b/crates/defguard_core/src/version.rs index e61142618..849c23233 100644 --- a/crates/defguard_core/src/version.rs +++ b/crates/defguard_core/src/version.rs @@ -9,7 +9,7 @@ use defguard_version::{ComponentInfo, Version, is_version_lower}; use serde::Serialize; use tonic::{Status, service::Interceptor}; -const MIN_PROXY_VERSION: Version = Version::new(1, 5, 0); +const MIN_PROXY_VERSION: Version = Version::new(1, 6, 0); pub const MIN_GATEWAY_VERSION: Version = Version::new(1, 5, 0); static OUTDATED_COMPONENT_LIFETIME: TimeDelta = TimeDelta::hours(1); From 2732768f9f0e68b9b9c6ff771866c85ef93db1f0 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:48:35 +0100 Subject: [PATCH 07/10] use encoded protos for passing platform --- .../defguard_core/src/grpc/client_version.rs | 198 ++---------------- 1 file changed, 18 insertions(+), 180 deletions(-) diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index 27ce8602c..9bf73f856 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -1,105 +1,11 @@ -use defguard_proto::proxy::DeviceInfo; +use base64::{Engine, prelude::BASE64_STANDARD}; +use defguard_proto::proxy::{ClientPlatformInfo, DeviceInfo}; +use prost::Message; use semver::Version; -#[derive(Debug)] -pub(crate) struct ClientPlatform { - /// The general OS family, e.g., "windows", "macos", "linux" - os_family: String, - /// Specific OS type, e.g., "Ubuntu", "Debian" - /// May sometimes be the same as `os_family`, e.g., "Windows" - #[allow(dead_code)] - os_type: String, - #[allow(dead_code)] - version: String, - #[allow(dead_code)] - architecture: Option, - #[allow(dead_code)] - edition: Option, - #[allow(dead_code)] - codename: Option, - /// "32-bit", "64-bit" - #[allow(dead_code)] - bitness: Option, -} - -impl TryFrom<&str> for ClientPlatform { - type Error = String; - - fn try_from(value: &str) -> Result { - let parts: Vec<&str> = value.split(';').collect(); - let mut os_family = None; - let mut os_type = None; - let mut version = None; - let mut architecture = None; - let mut edition = None; - let mut codename = None; - let mut bitness = None; - - let to_option: fn(&str) -> Option = |s: &str| { - let trimmed = s.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }; - - // The expected format is: - // "os_family={}; os_type={}; version={}; edition={}; codename={}; bitness={}; architecture={}", - for part in parts { - let kv: Vec<&str> = part.trim().splitn(2, '=').collect(); - if kv.len() != 2 { - continue; - } - match kv[0].trim().to_lowercase().as_str() { - "os_family" => { - os_family = to_option(kv[1]); - } - "os_type" => { - os_type = to_option(kv[1]); - } - "version" => { - version = to_option(kv[1]); - } - "architecture" => { - architecture = to_option(kv[1]); - } - "edition" => { - edition = to_option(kv[1]); - } - "codename" => { - codename = to_option(kv[1]); - } - "bitness" => { - bitness = to_option(kv[1]); - } - _ => {} - } - } - - let (Some(os_family), Some(os_type), Some(version)) = (os_family, os_type, version) else { - let msg = format!( - "invalid client platform string: {value}. OS family, its concrete type and version are required." - ); - error!(msg); - return Err(msg); - }; - - Ok(Self { - os_family, - os_type, - version, - architecture, - edition, - codename, - bitness, - }) - } -} - pub(crate) fn parse_client_version_platform( info: Option<&DeviceInfo>, -) -> (Option, Option) { +) -> (Option, Option) { let Some(info) = info else { debug!("Device information is missing from the request"); return (None, None); @@ -119,13 +25,20 @@ pub(crate) fn parse_client_version_platform( ); let platform = info.platform.as_ref().and_then(|p| { - ClientPlatform::try_from(p.as_str()).map_or_else( - |_| { - error!("Invalid platform string: {p}"); - None - }, - Some, - ) + let binary = BASE64_STANDARD + .decode(p) + .map_err(|e| { + error!("Failed to decode base64 platform string: {e}"); + e + }) + .ok()?; + let platform_info = ClientPlatformInfo::decode(&*binary) + .map_err(|e| { + error!("Failed to decode ClientPlatformInfo from bytes: {e}"); + e + }) + .ok()?; + Some(platform_info) }); (version, platform) @@ -204,81 +117,6 @@ mod tests { } } - #[test] - fn test_client_platform_try_from() { - // Test valid full platform string - let platform_str = "os_family=linux; os_type=Ubuntu; version=22.04; architecture=x86_64; edition=Desktop; codename=jammy; bitness=64-bit"; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_ok()); - let platform = result.unwrap(); - assert_eq!(platform.os_family, "linux"); - assert_eq!(platform.os_type, "Ubuntu"); - assert_eq!(platform.version, "22.04"); - assert_eq!(platform.architecture, Some("x86_64".to_string())); - assert_eq!(platform.edition, Some("Desktop".to_string())); - assert_eq!(platform.codename, Some("jammy".to_string())); - assert_eq!(platform.bitness, Some("64-bit".to_string())); - - // Test minimal valid platform string (only required fields) - let platform_str = "os_family=windows; os_type=Windows; version=11"; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_ok()); - let platform = result.unwrap(); - assert_eq!(platform.os_family, "windows"); - assert_eq!(platform.os_type, "Windows"); - assert_eq!(platform.version, "11"); - assert_eq!(platform.architecture, None); - - // Test with empty optional fields - let platform_str = "os_family=macos; os_type=macOS; version=14.0; architecture=; edition=; codename=; bitness="; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_ok()); - let platform = result.unwrap(); - assert_eq!(platform.os_family, "macos"); - assert_eq!(platform.architecture, None); - assert_eq!(platform.edition, None); - - // Test missing required field (os_family) - let platform_str = "os_type=Ubuntu; version=22.04"; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("OS family")); - - // Test missing required field (os_type) - let platform_str = "os_family=linux; version=22.04"; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_err()); - - // Test missing required field (version) - let platform_str = "os_family=linux; os_type=Ubuntu"; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_err()); - - // Test with extra whitespace - let platform_str = " os_family = linux ; os_type = Ubuntu ; version = 22.04 "; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_ok()); - let platform = result.unwrap(); - assert_eq!(platform.os_family, "linux"); - assert_eq!(platform.os_type, "Ubuntu"); - assert_eq!(platform.version, "22.04"); - - // Test with unknown keys (should be ignored) - let platform_str = "os_family=linux; os_type=Ubuntu; version=22.04; unknown_key=value"; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_ok()); - - // Test with malformed key-value pairs (missing equals sign) - let platform_str = "os_family=linux; os_type=Ubuntu; version=22.04; malformed_field"; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_ok()); - - // Test empty string - let platform_str = ""; - let result = ClientPlatform::try_from(platform_str); - assert!(result.is_err()); - } - #[test] fn test_parse_client_version_platform() { // Test with valid version and platform From 842c8ba499556f6ce0a62205744cbfd033eae18a Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:04:32 +0100 Subject: [PATCH 08/10] fix --- crates/defguard_core/src/grpc/client_version.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index 9bf73f856..a7dceb646 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -76,10 +76,8 @@ impl ClientFeature { if !version_matches { debug!( - "Client version {:?} does not meet minimum version {:?} for feature {:?}", - version, - self.min_version(), - self + "Client version {version:?} does not meet minimum version {:?} for feature {self:?}", + self.min_version() ); } From d6af81e05575dd28f346990f4faf34dcea62536e Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:07:27 +0100 Subject: [PATCH 09/10] Update proto --- proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto b/proto index fee706013..96249ebde 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit fee706013b3bb5452c3c4dbf35bd973d0637ff25 +Subproject commit 96249ebde0556f4ae8c47eebc6015efb04ed0104 From 8abab905508af738365c4ca602cb3087576d36a9 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:36:43 +0100 Subject: [PATCH 10/10] fix tests --- .../defguard_core/src/grpc/client_version.rs | 129 ++++++++++++++---- 1 file changed, 100 insertions(+), 29 deletions(-) diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs index a7dceb646..d8bc7be95 100644 --- a/crates/defguard_core/src/grpc/client_version.rs +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -107,7 +107,16 @@ mod tests { use super::*; // Helper function to create DeviceInfo - fn create_device_info(version: Option, platform: Option) -> DeviceInfo { + fn create_device_info( + version: Option, + platform: Option, + ) -> DeviceInfo { + let platform = platform.map(|p| { + let mut buf = Vec::new(); + p.encode(&mut buf).unwrap(); + BASE64_STANDARD.encode(&buf) + }); + DeviceInfo { version, platform, @@ -120,7 +129,12 @@ mod tests { // Test with valid version and platform let info = create_device_info( Some("1.5.0".to_string()), - Some("os_family=windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); let (version, platform) = parse_client_version_platform(Some(&info)); assert!(version.is_some()); @@ -136,22 +150,26 @@ mod tests { // Test with invalid version string let info = create_device_info( Some("invalid.version".to_string()), - Some("os_family=linux; os_type=Ubuntu; version=22.04".to_string()), + Some(ClientPlatformInfo { + os_family: "linux".to_string(), + os_type: "Ubuntu".to_string(), + version: "22.04".to_string(), + ..Default::default() + }), ); let (version, platform) = parse_client_version_platform(Some(&info)); assert!(version.is_none()); assert!(platform.is_some()); - // Test with invalid platform string - let info = create_device_info(Some("1.5.0".to_string()), Some("invalid".to_string())); - let (version, platform) = parse_client_version_platform(Some(&info)); - assert!(version.is_some()); - assert!(platform.is_none()); - // Test with missing version field let info = create_device_info( None, - Some("os_family=linux; os_type=Ubuntu; version=22.04".to_string()), + Some(ClientPlatformInfo { + os_family: "linux".to_string(), + os_type: "Ubuntu".to_string(), + version: "22.04".to_string(), + ..Default::default() + }), ); let (version, platform) = parse_client_version_platform(Some(&info)); assert!(version.is_none()); @@ -172,7 +190,12 @@ mod tests { // Test with pre-release version let info = create_device_info( Some("1.5.0-alpha1".to_string()), - Some("os_family=macos; os_type=macOS; version=14.0".to_string()), + Some(ClientPlatformInfo { + os_family: "macos".to_string(), + os_type: "macOS".to_string(), + version: "14.0".to_string(), + ..Default::default() + }), ); let (version, platform) = parse_client_version_platform(Some(&info)); assert!(version.is_some()); @@ -185,7 +208,12 @@ mod tests { // Test ServiceLocations feature with supported version and OS let info = create_device_info( Some("1.6.0".to_string()), - Some("os_family=windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -195,7 +223,12 @@ mod tests { // Test with exact minimum version let info = create_device_info( Some("1.6.0".to_string()), - Some("os_family=Windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "Windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -205,7 +238,12 @@ mod tests { // Test with higher version let info = create_device_info( Some("2.0.0".to_string()), - Some("os_family=WINDOWS; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "WINDOWS".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -215,7 +253,12 @@ mod tests { // Test with version below minimum let info = create_device_info( Some("1.5.9".to_string()), - Some("os_family=windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -225,7 +268,12 @@ mod tests { // Test with wrong OS family (linux) let info = create_device_info( Some("1.6.0".to_string()), - Some("os_family=linux; os_type=Ubuntu; version=22.04".to_string()), + Some(ClientPlatformInfo { + os_family: "linux".to_string(), + os_type: "Ubuntu".to_string(), + version: "22.04".to_string(), + ..Default::default() + }), ); assert!( !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -235,7 +283,12 @@ mod tests { // Test with wrong OS family (macos) let info = create_device_info( Some("1.6.0".to_string()), - Some("os_family=macos; os_type=macOS; version=14.0".to_string()), + Some(ClientPlatformInfo { + os_family: "macos".to_string(), + os_type: "macOS".to_string(), + version: "14.0".to_string(), + ..Default::default() + }), ); assert!( !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -251,7 +304,12 @@ mod tests { // Test with missing version let info = create_device_info( None, - Some("os_family=windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -268,24 +326,27 @@ mod tests { // Test with invalid version string let info = create_device_info( Some("invalid".to_string()), - Some("os_family=windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), "ServiceLocations should not be supported with invalid version" ); - // Test with invalid platform string - let info = create_device_info(Some("1.6.0".to_string()), Some("invalid".to_string())); - assert!( - !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), - "ServiceLocations should not be supported with invalid platform" - ); - // Test case insensitivity of OS family matching let info = create_device_info( Some("1.6.0".to_string()), - Some("os_family=WiNdOwS; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "WiNdOwS".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -295,7 +356,12 @@ mod tests { // Test with pre-release version above minimum let info = create_device_info( Some("1.7.0-alpha1".to_string()), - Some("os_family=windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), @@ -305,7 +371,12 @@ mod tests { // Test with pre-release version below minimum let info = create_device_info( Some("1.5.0-alpha1".to_string()), - Some("os_family=windows; os_type=Windows; version=11".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), ); assert!( !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)),