Skip to main content

inline_sdk/
client_info.rs

1//! Client identity and metadata helpers shared by HTTP and realtime clients.
2
3use reqwest::header::{HeaderMap, HeaderValue};
4use std::process::Command;
5use thiserror::Error;
6
7/// Default client type used when callers do not provide an application identity.
8pub const SDK_CLIENT_TYPE: &str = "rust-sdk";
9/// HTTP header carrying the Inline client type.
10pub const CLIENT_TYPE_HEADER: &str = "x-inline-client-type";
11/// HTTP header carrying the Inline client version.
12pub const CLIENT_VERSION_HEADER: &str = "x-inline-client-version";
13
14/// Error returned when a client identity cannot be represented in Inline headers.
15#[derive(Clone, Debug, Error, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum ClientIdentityError {
18    /// The given field is empty after trimming whitespace.
19    #[error("{field} cannot be empty")]
20    Empty {
21        /// Name of the invalid identity field.
22        field: &'static str,
23    },
24    /// The given field contains bytes rejected by `HeaderValue`.
25    #[error("{field} contains characters that are invalid in HTTP headers")]
26    InvalidHeaderValue {
27        /// Name of the invalid identity field.
28        field: &'static str,
29    },
30}
31
32/// Application identity sent with Inline HTTP and realtime requests.
33///
34/// Use `ClientIdentity::sdk()` for low-level SDK callers, or pass your own
35/// application-specific type such as `cli`, `matrix-bridge`, or `agent`.
36#[must_use]
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct ClientIdentity {
39    client_type: String,
40    client_version: String,
41}
42
43impl ClientIdentity {
44    /// Creates a client identity and panics if the values are invalid.
45    ///
46    /// This is intended for static or already-validated values. Use
47    /// [`ClientIdentity::try_new`] when accepting user or config input.
48    ///
49    /// # Panics
50    ///
51    /// Panics when either field is empty after trimming or cannot be represented
52    /// as an HTTP header value.
53    pub fn new(
54        client_type: impl Into<String>,
55        client_version: impl Into<String>,
56    ) -> ClientIdentity {
57        Self::try_new(client_type, client_version).expect("client identity must be valid")
58    }
59
60    /// Creates a client identity after trimming and validating header values.
61    pub fn try_new(
62        client_type: impl Into<String>,
63        client_version: impl Into<String>,
64    ) -> Result<ClientIdentity, ClientIdentityError> {
65        let client_type = normalize_header_component("client_type", client_type.into())?;
66        let client_version = normalize_header_component("client_version", client_version.into())?;
67        Ok(Self {
68            client_type,
69            client_version,
70        })
71    }
72
73    /// Returns the default SDK identity for this crate version.
74    pub fn sdk() -> ClientIdentity {
75        ClientIdentity::new(SDK_CLIENT_TYPE, sdk_version())
76    }
77
78    /// Returns the Inline client type, such as `rust-sdk` or `cli`.
79    pub fn client_type(&self) -> &str {
80        &self.client_type
81    }
82
83    /// Returns the application version sent to Inline.
84    pub fn client_version(&self) -> &str {
85        &self.client_version
86    }
87}
88
89impl Default for ClientIdentity {
90    fn default() -> Self {
91        Self::sdk()
92    }
93}
94
95/// Login metadata sent with auth code and verification requests.
96///
97/// `client` remains the caller's transport identity for headers, user agents,
98/// and realtime handshakes. The HTTP auth body maps identities that the server
99/// cannot store as session client types to a compatible session type.
100#[must_use]
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct AuthMetadata {
103    device_id: String,
104    device_name: Option<String>,
105    client: ClientIdentity,
106}
107
108impl AuthMetadata {
109    /// Creates auth metadata for a durable device id and client identity.
110    pub fn new(device_id: impl Into<String>, client: ClientIdentity) -> Self {
111        Self {
112            device_id: device_id.into(),
113            device_name: None,
114            client,
115        }
116    }
117
118    /// Creates auth metadata using the default SDK client identity.
119    pub fn sdk(device_id: impl Into<String>) -> Self {
120        Self::new(device_id, ClientIdentity::sdk())
121    }
122
123    /// Attaches a human-readable device name.
124    pub fn with_device_name(mut self, device_name: impl Into<String>) -> Self {
125        let device_name = device_name.into().trim().to_string();
126        if !device_name.is_empty() {
127            self.device_name = Some(device_name);
128        }
129        self
130    }
131
132    /// Returns the durable device id.
133    pub fn device_id(&self) -> &str {
134        &self.device_id
135    }
136
137    /// Returns the optional human-readable device name.
138    pub fn device_name(&self) -> Option<&str> {
139        self.device_name.as_deref()
140    }
141
142    /// Returns the client identity used for auth requests.
143    pub fn client(&self) -> &ClientIdentity {
144        &self.client
145    }
146}
147
148/// Returns the version of the `inline-sdk` crate.
149pub fn sdk_version() -> &'static str {
150    env!("CARGO_PKG_VERSION")
151}
152
153/// Builds the default SDK user agent.
154pub fn user_agent() -> String {
155    user_agent_for(&ClientIdentity::sdk())
156}
157
158/// Builds a user agent for a specific client identity.
159pub fn user_agent_for(identity: &ClientIdentity) -> String {
160    format!("{}/{}", identity.client_type(), identity.client_version())
161}
162
163/// Returns a best-effort local device name.
164pub fn device_name() -> Option<String> {
165    hostname::get()
166        .ok()
167        .and_then(|name| name.into_string().ok())
168        .map(|name| name.trim().to_string())
169        .filter(|name| !name.is_empty())
170}
171
172/// Builds default Inline HTTP headers for the SDK identity.
173pub fn default_http_headers() -> HeaderMap {
174    default_http_headers_for(&ClientIdentity::sdk())
175}
176
177/// Builds Inline HTTP headers for a specific client identity.
178pub fn default_http_headers_for(identity: &ClientIdentity) -> HeaderMap {
179    try_default_http_headers_for(identity).expect("validated client identity must fit HTTP headers")
180}
181
182/// Builds Inline HTTP headers for a specific client identity.
183pub fn try_default_http_headers_for(
184    identity: &ClientIdentity,
185) -> Result<HeaderMap, ClientIdentityError> {
186    let mut headers = HeaderMap::new();
187    headers.insert(
188        CLIENT_TYPE_HEADER,
189        header_value("client_type", identity.client_type())?,
190    );
191    headers.insert(
192        CLIENT_VERSION_HEADER,
193        header_value("client_version", identity.client_version())?,
194    );
195    Ok(headers)
196}
197
198/// Builds a `reqwest` client builder configured with SDK identity headers.
199pub fn http_client_builder() -> reqwest::ClientBuilder {
200    reqwest::Client::builder()
201        .default_headers(default_http_headers())
202        .user_agent(user_agent())
203}
204
205/// Builds a `reqwest` client builder configured with a specific identity.
206pub fn http_client_builder_for(identity: &ClientIdentity) -> reqwest::ClientBuilder {
207    reqwest::Client::builder()
208        .default_headers(default_http_headers_for(identity))
209        .user_agent(user_agent_for(identity))
210}
211
212/// Returns a best-effort operating system version string for realtime init.
213pub fn current_os_version() -> Option<String> {
214    let mut cmd = match std::env::consts::OS {
215        "macos" => {
216            let mut cmd = Command::new("sw_vers");
217            cmd.arg("-productVersion");
218            cmd
219        }
220        "linux" => {
221            let mut cmd = Command::new("uname");
222            cmd.arg("-r");
223            cmd
224        }
225        "windows" => {
226            let mut cmd = Command::new("cmd");
227            cmd.args(["/C", "ver"]);
228            cmd
229        }
230        _ => return Some(std::env::consts::OS.to_string()),
231    };
232
233    let output = cmd.output().ok()?;
234    if !output.status.success() {
235        return Some(std::env::consts::OS.to_string());
236    }
237
238    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
239    if value.is_empty() {
240        return Some(std::env::consts::OS.to_string());
241    }
242
243    Some(value)
244}
245
246fn normalize_header_component(
247    field: &'static str,
248    value: String,
249) -> Result<String, ClientIdentityError> {
250    let value = value.trim().to_string();
251    if value.is_empty() {
252        return Err(ClientIdentityError::Empty { field });
253    }
254    HeaderValue::from_str(&value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })?;
255    Ok(value)
256}
257
258fn header_value(field: &'static str, value: &str) -> Result<HeaderValue, ClientIdentityError> {
259    HeaderValue::from_str(value).map_err(|_| ClientIdentityError::InvalidHeaderValue { field })
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use reqwest::header::USER_AGENT;
266
267    #[test]
268    fn default_headers_identify_sdk_without_user_agent() {
269        let headers = default_http_headers();
270        assert_eq!(
271            headers
272                .get(CLIENT_TYPE_HEADER)
273                .and_then(|value| value.to_str().ok()),
274            Some("rust-sdk")
275        );
276        assert_eq!(
277            headers
278                .get(CLIENT_VERSION_HEADER)
279                .and_then(|value| value.to_str().ok()),
280            Some(sdk_version())
281        );
282        assert!(headers.get(USER_AGENT).is_none());
283    }
284
285    #[test]
286    fn default_headers_can_use_custom_client_identity() {
287        let identity = ClientIdentity::new("my-agent", "0.1.0");
288        let headers = default_http_headers_for(&identity);
289        assert_eq!(
290            headers
291                .get(CLIENT_TYPE_HEADER)
292                .and_then(|value| value.to_str().ok()),
293            Some("my-agent")
294        );
295        assert_eq!(
296            headers
297                .get(CLIENT_VERSION_HEADER)
298                .and_then(|value| value.to_str().ok()),
299            Some("0.1.0")
300        );
301        assert!(headers.get(USER_AGENT).is_none());
302    }
303
304    #[test]
305    fn fallible_default_headers_can_use_custom_client_identity() {
306        let identity = ClientIdentity::new("my-agent", "0.1.0");
307        let headers = try_default_http_headers_for(&identity).unwrap();
308        assert_eq!(
309            headers
310                .get(CLIENT_TYPE_HEADER)
311                .and_then(|value| value.to_str().ok()),
312            Some("my-agent")
313        );
314        assert_eq!(
315            headers
316                .get(CLIENT_VERSION_HEADER)
317                .and_then(|value| value.to_str().ok()),
318            Some("0.1.0")
319        );
320    }
321
322    #[test]
323    fn user_agent_identifies_sdk_version() {
324        assert_eq!(user_agent(), format!("rust-sdk/{}", sdk_version()));
325    }
326
327    #[test]
328    fn user_agent_can_use_custom_client_identity() {
329        let identity = ClientIdentity::new("my-agent", "0.1.0");
330        assert_eq!(user_agent_for(&identity), "my-agent/0.1.0");
331    }
332
333    #[test]
334    fn http_client_builder_builds_with_sdk_metadata() {
335        assert!(http_client_builder().build().is_ok());
336    }
337
338    #[test]
339    fn http_client_builder_builds_with_custom_client_identity() {
340        let identity = ClientIdentity::new("my-agent", "0.1.0");
341        assert!(http_client_builder_for(&identity).build().is_ok());
342    }
343
344    #[test]
345    fn auth_metadata_stores_device_context() {
346        let identity = ClientIdentity::new("agent", "0.1.0");
347        let metadata = AuthMetadata::new("device-1", identity.clone()).with_device_name("mo-mac");
348        assert_eq!(metadata.device_id(), "device-1");
349        assert_eq!(metadata.device_name(), Some("mo-mac"));
350        assert_eq!(metadata.client(), &identity);
351    }
352
353    #[test]
354    fn auth_metadata_ignores_blank_device_name() {
355        let metadata = AuthMetadata::sdk("device-1").with_device_name("  ");
356        assert_eq!(metadata.device_id(), "device-1");
357        assert_eq!(metadata.device_name(), None);
358    }
359
360    #[test]
361    fn client_identity_rejects_empty_or_invalid_values() {
362        assert_eq!(
363            ClientIdentity::try_new("", "0.1.0").unwrap_err(),
364            ClientIdentityError::Empty {
365                field: "client_type"
366            }
367        );
368        assert_eq!(
369            ClientIdentity::try_new("agent", "0.1\n0").unwrap_err(),
370            ClientIdentityError::InvalidHeaderValue {
371                field: "client_version"
372            }
373        );
374    }
375
376    #[test]
377    fn device_name_is_never_empty() {
378        assert!(
379            device_name()
380                .as_deref()
381                .map(|name| !name.is_empty())
382                .unwrap_or(true)
383        );
384    }
385}