Skip to main content

simulator_api/
client_identity.rs

1//! `User-Agent` contract between our clients and the manager. Rendered by
2//! `simulator-client` / `sim-cli`, parsed by the manager on the control-WS
3//! upgrade. Both sides reference these consts so the two cannot drift.
4
5pub const PRODUCT_SDK: &str = "nitro-sim-client";
6pub const PRODUCT_CLI: &str = "nitro-sim-cli";
7pub const MANAGED_MARKER: &str = "(managed)";
8
9/// Tag value when no known product token was found. Never an empty string:
10/// line protocol handles empty tag values inconsistently.
11pub 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    /// Sent no User-Agent: either a pre-upgrade SDK or a bare custom client.
41    Unknown,
42}
43
44impl ClientKind {
45    /// Dashboard-visible: these values MUST stay byte-identical once shipped.
46    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
57/// `version` names the leading product's own release; `sdk_version` the client library's. They
58/// differ for the CLI, whose binary embeds the SDK — each token then reports its own release.
59pub 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
79/// Classify by the **leading** product token, so the CLI's own token wins over
80/// the SDK token it also carries. The version is returned only for a token we
81/// recognize, which keeps the `client_version` metric tag bounded by our own
82/// releases rather than by arbitrary strings.
83pub 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
103/// The header travels verbatim into the metrics tag, so a version is accepted only when it
104/// looks like one of ours: `[A-Za-z0-9_.-]`, at most this long. Anything else reads as unknown
105/// rather than reaching the metrics infrastructure.
106const 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}