simulator_api/
client_identity.rs1pub const PRODUCT_SDK: &str = "nitro-sim-client";
6pub const PRODUCT_CLI: &str = "nitro-sim-cli";
7pub const MANAGED_MARKER: &str = "(managed)";
8
9pub const UNKNOWN_VERSION: &str = "unknown";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ClientProduct {
15 Sdk,
16 Cli,
17}
18
19impl ClientProduct {
20 const fn token(self) -> &'static str {
21 match self {
22 Self::Sdk => PRODUCT_SDK,
23 Self::Cli => PRODUCT_CLI,
24 }
25 }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ClientSurface {
30 Raw,
31 Managed,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ClientKind {
36 Managed,
37 Raw,
38 Cli,
39 Custom,
40 Unknown,
42}
43
44impl ClientKind {
45 pub const fn as_metric_value(self) -> &'static str {
47 match self {
48 Self::Managed => "managed",
49 Self::Raw => "raw",
50 Self::Cli => "cli",
51 Self::Custom => "custom",
52 Self::Unknown => "unknown",
53 }
54 }
55}
56
57pub fn render(
60 product: ClientProduct,
61 surface: ClientSurface,
62 version: &str,
63 sdk_version: &str,
64) -> String {
65 let mut user_agent = format!("{}/{version}", product.token());
66 if product == ClientProduct::Cli {
67 user_agent.push(' ');
68 user_agent.push_str(PRODUCT_SDK);
69 user_agent.push('/');
70 user_agent.push_str(sdk_version);
71 }
72 if surface == ClientSurface::Managed {
73 user_agent.push(' ');
74 user_agent.push_str(MANAGED_MARKER);
75 }
76 user_agent
77}
78
79pub fn classify(user_agent: Option<&str>) -> (ClientKind, &str) {
84 let Some(user_agent) = user_agent.map(str::trim).filter(|value| !value.is_empty()) else {
85 return (ClientKind::Unknown, UNKNOWN_VERSION);
86 };
87 let Some((token, version)) = user_agent
88 .split_whitespace()
89 .next()
90 .and_then(|leading| leading.split_once('/'))
91 else {
92 return (ClientKind::Custom, UNKNOWN_VERSION);
93 };
94 let version = accepted_version(version);
95 match token {
96 PRODUCT_CLI => (ClientKind::Cli, version),
97 PRODUCT_SDK if user_agent.contains(MANAGED_MARKER) => (ClientKind::Managed, version),
98 PRODUCT_SDK => (ClientKind::Raw, version),
99 _ => (ClientKind::Custom, UNKNOWN_VERSION),
100 }
101}
102
103const MAX_VERSION_LEN: usize = 32;
107
108fn accepted_version(version: &str) -> &str {
109 let valid = !version.is_empty()
110 && version.len() <= MAX_VERSION_LEN
111 && version
112 .bytes()
113 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'));
114 if valid { version } else { UNKNOWN_VERSION }
115}
116
117#[cfg(test)]
118mod tests {
119 use rstest::rstest;
120
121 use super::*;
122
123 #[rstest]
124 #[case::raw_sdk(ClientProduct::Sdk, ClientSurface::Raw, ClientKind::Raw)]
125 #[case::managed_sdk(ClientProduct::Sdk, ClientSurface::Managed, ClientKind::Managed)]
126 #[case::cli_raw(ClientProduct::Cli, ClientSurface::Raw, ClientKind::Cli)]
127 #[case::cli_managed(ClientProduct::Cli, ClientSurface::Managed, ClientKind::Cli)]
128 fn rendered_identities_classify_back(
129 #[case] product: ClientProduct,
130 #[case] surface: ClientSurface,
131 #[case] expected: ClientKind,
132 ) {
133 let user_agent = render(product, surface, "0.12.3", "0.12.3");
134 assert_eq!(classify(Some(&user_agent)), (expected, "0.12.3"));
135 }
136
137 #[rstest]
138 #[case::absent(None, ClientKind::Unknown)]
139 #[case::empty(Some(""), ClientKind::Unknown)]
140 #[case::python(Some("python-requests/2.31.0"), ClientKind::Custom)]
141 #[case::garbage(Some("!!!"), ClientKind::Custom)]
142 #[case::foreign_managed_marker(Some("my-bot/1.0 (managed)"), ClientKind::Custom)]
143 fn foreign_and_absent_user_agents(
144 #[case] user_agent: Option<&str>,
145 #[case] expected: ClientKind,
146 ) {
147 assert_eq!(classify(user_agent), (expected, UNKNOWN_VERSION));
148 }
149
150 #[test]
151 fn cli_token_wins_over_the_sdk_token_it_carries() {
152 let user_agent = format!("{PRODUCT_CLI}/1.2.3 {PRODUCT_SDK}/0.12.3 {MANAGED_MARKER}");
153 assert_eq!(classify(Some(&user_agent)), (ClientKind::Cli, "1.2.3"));
154 }
155
156 #[rstest]
157 #[case::semver("1.2.3-rc.1_hotfix", "1.2.3-rc.1_hotfix")]
158 #[case::injection("1.2.3\",attack=\"x", UNKNOWN_VERSION)]
159 #[case::overlong("1.0.0-padpadpadpadpadpadpadpadpadpad", UNKNOWN_VERSION)]
160 #[case::empty_version("", UNKNOWN_VERSION)]
161 fn version_tag_is_bounded_before_reaching_metrics(
162 #[case] version: &str,
163 #[case] expected: &str,
164 ) {
165 let user_agent = format!("{PRODUCT_CLI}/{version}");
166 assert_eq!(classify(Some(&user_agent)), (ClientKind::Cli, expected));
167 }
168}