diff --git a/bedrock/src/lib.rs b/bedrock/src/lib.rs index 0e51af3e..f82f6ae8 100644 --- a/bedrock/src/lib.rs +++ b/bedrock/src/lib.rs @@ -36,7 +36,9 @@ pub mod backup; pub mod nitro_enclave; // Re-export commonly used primitives at the crate root for convenience -pub use primitives::{AuthenticatedHttpClient, HttpError, HttpMethod}; +pub use primitives::{ + AuthenticatedHttpClient, HttpError, HttpMethod, UserAgent, UserAgentBuilder, +}; /// Key management for World App. mod root_key; diff --git a/bedrock/src/primitives/mod.rs b/bedrock/src/primitives/mod.rs index 9b010484..2381d7b0 100644 --- a/bedrock/src/primitives/mod.rs +++ b/bedrock/src/primitives/mod.rs @@ -10,6 +10,7 @@ use std::str::FromStr; // Re-export HTTP client types for external use pub use http_client::{AuthenticatedHttpClient, HttpError, HttpMethod}; +pub use user_agent::{UserAgent, UserAgentBuilder}; /// The prefix for Bedrock-generated transactions. pub static BEDROCK_NONCE_PREFIX_CONST: &[u8; 5] = b"bdrck"; @@ -59,6 +60,9 @@ pub mod filesystem; /// Introduces authenticated HTTP client functionality that native applications must implement for bedrock. pub mod http_client; +/// Introduces User-Agent helpers for requests issued through Bedrock consumers. +pub mod user_agent; + /// Introduces key-value store functionality for persisting device data. pub mod key_value_store; diff --git a/bedrock/src/primitives/user_agent.rs b/bedrock/src/primitives/user_agent.rs new file mode 100644 index 00000000..c3b61131 --- /dev/null +++ b/bedrock/src/primitives/user_agent.rs @@ -0,0 +1,187 @@ +//! User-Agent helpers for HTTP requests issued through Bedrock consumers. + +use std::fmt; + +const WORLD_APP_USER_AGENT_PRODUCT: &str = "WorldApp"; +const WORLD_ID_APP_USER_AGENT_PRODUCT: &str = "WorldID"; +const WORLD_ID_ANDROID_CLIENT_NAME: &str = "android-id"; +const WORLD_ID_IOS_CLIENT_NAME: &str = "ios-id"; + +/// Represents a complete HTTP `User-Agent` header value. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)] +pub struct UserAgent(String); + +impl fmt::Display for UserAgent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[uniffi::export] +impl UserAgent { + /// Returns the complete HTTP `User-Agent` header value. + #[must_use] + pub fn header_value(&self) -> String { + self.0.clone() + } +} + +/// Builds the [`UserAgent`] string sent as the HTTP `User-Agent` header. +/// +/// Starts empty; call [`Self::with_segment`] for arbitrary `name/version` +/// tokens and the Bedrock-specific helpers for app, library, and client +/// segments. +#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Object)] +pub struct UserAgentBuilder { + segments: Vec, +} + +#[uniffi::export] +impl UserAgentBuilder { + /// Creates an empty [`UserAgentBuilder`]. + #[uniffi::constructor] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Appends an arbitrary `name/version` segment. + #[must_use] + pub fn with_segment(&self, name: &str, version: &str) -> Self { + let mut next = self.clone(); + next.segments.push(format!("{name}/{version}")); + next + } + + /// Appends the app product segment for the client name. + /// + /// Uses `WorldID/{app_version}` for World ID app clients + /// (`android-id` / `ios-id`), and `WorldApp/{app_version}` for all + /// other clients. + #[must_use] + pub fn with_app_segment_for_client( + &self, + app_version: &str, + client_name: &str, + ) -> Self { + self.with_segment(user_agent_product_for_client(client_name), app_version) + } + + /// Appends `bedrock/{crate version}`. + #[must_use] + pub fn with_bedrock_segment(&self) -> Self { + self.with_segment("bedrock", env!("CARGO_PKG_VERSION")) + } + + /// Finalizes the header value as [`UserAgent`]. + #[must_use] + pub fn build(&self) -> UserAgent { + UserAgent(self.segments.join(" ")) + } +} + +fn user_agent_product_for_client(client_name: &str) -> &'static str { + match client_name { + WORLD_ID_ANDROID_CLIENT_NAME | WORLD_ID_IOS_CLIENT_NAME => { + WORLD_ID_APP_USER_AGENT_PRODUCT + } + _ => WORLD_APP_USER_AGENT_PRODUCT, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_user_agent( + app_version: &str, + client_name: &str, + os_version: &str, + ) -> UserAgent { + UserAgentBuilder::new() + .with_app_segment_for_client(app_version, client_name) + .with_bedrock_segment() + .with_segment(client_name, os_version) + .build() + } + + #[test] + fn user_agent_builder_starts_empty() { + assert_eq!(UserAgentBuilder::new().build().to_string(), ""); + } + + #[test] + fn user_agent_builder_appends_arbitrary_segments() { + let user_agent = UserAgentBuilder::new() + .with_segment("CLI", "1.2.3") + .with_bedrock_segment() + .build(); + + assert_eq!( + user_agent.to_string(), + concat!("CLI/1.2.3 bedrock/", env!("CARGO_PKG_VERSION")) + ); + } + + #[test] + fn world_app_android_client_uses_world_app_product_name() { + assert_eq!( + app_user_agent("4.0.2500", "android", "15").to_string(), + concat!( + "WorldApp/4.0.2500 bedrock/", + env!("CARGO_PKG_VERSION"), + " android/15" + ) + ); + } + + #[test] + fn world_app_ios_client_uses_world_app_product_name() { + assert_eq!( + app_user_agent("4.0.2500", "ios", "26.4.2").to_string(), + concat!( + "WorldApp/4.0.2500 bedrock/", + env!("CARGO_PKG_VERSION"), + " ios/26.4.2" + ) + ); + } + + #[test] + fn world_id_android_client_uses_world_id_product_name() { + assert_eq!( + app_user_agent("1.0.100", "android-id", "15").to_string(), + concat!( + "WorldID/1.0.100 bedrock/", + env!("CARGO_PKG_VERSION"), + " android-id/15" + ) + ); + } + + #[test] + fn world_id_ios_client_uses_world_id_product_name() { + assert_eq!( + app_user_agent("1.0.100", "ios-id", "26.4.2").to_string(), + concat!( + "WorldID/1.0.100 bedrock/", + env!("CARGO_PKG_VERSION"), + " ios-id/26.4.2" + ) + ); + } + + #[test] + fn user_agent_exposes_header_value_for_ffi_consumers() { + let user_agent = app_user_agent("1.0.100", "android-id", "15"); + + assert_eq!( + user_agent.header_value(), + concat!( + "WorldID/1.0.100 bedrock/", + env!("CARGO_PKG_VERSION"), + " android-id/15" + ) + ); + } +} diff --git a/deny.toml b/deny.toml index b471ccca..a90ee5e9 100644 --- a/deny.toml +++ b/deny.toml @@ -51,4 +51,5 @@ license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] ignore = [ "RUSTSEC-2024-0436", # Unmaintained `paste` (2025-04-04) "RUSTSEC-2021-0127", # serde_cbor is unmaintained. We use this in AWS NSM package but just for the AttestationDocument Type + "RUSTSEC-2026-0173", # proc-macro-error2 is unmaintained; transitive dep via alloy -> alloy-sol-types -> alloy-sol-macro, no safe upgrade available ]