relay_event_normalization/normalize/
contexts.rs

1//! Computation and normalization of contexts from event data.
2
3use std::collections::HashMap;
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7use relay_event_schema::protocol::{
8    BrowserContext, Context, Cookies, OsContext, ResponseContext, RuntimeContext,
9};
10use relay_protocol::{Annotated, Empty, Value};
11
12/// Environment.OSVersion (GetVersionEx) or RuntimeInformation.OSDescription on Windows
13static OS_WINDOWS_REGEX1: Lazy<Regex> = Lazy::new(|| {
14    Regex::new(r"^(Microsoft\s+)?Windows\s+(NT\s+)?(?P<version>\d+\.\d+\.(?P<build_number>\d+)).*$")
15        .unwrap()
16});
17static OS_WINDOWS_REGEX2: Lazy<Regex> = Lazy::new(|| {
18    Regex::new(r"^Windows\s+\d+\s+\((?P<version>\d+\.\d+\.(?P<build_number>\d+)).*$").unwrap()
19});
20
21static OS_ANDROID_REGEX: Lazy<Regex> = Lazy::new(|| {
22    Regex::new(r"^Android (OS )?(?P<version>\d+(\.\d+){0,2}) / API-(?P<api>(\d+))").unwrap()
23});
24
25/// Format sent by Unreal Engine on macOS
26static OS_MACOS_REGEX: Lazy<Regex> = Lazy::new(|| {
27    Regex::new(r"^Mac OS X (?P<version>\d+\.\d+\.\d+)( \((?P<build>[a-fA-F0-9]+)\))?$").unwrap()
28});
29
30/// Format sent by Unity on iOS
31static OS_IOS_REGEX: Lazy<Regex> =
32    Lazy::new(|| Regex::new(r"^iOS (?P<version>\d+\.\d+\.\d+)").unwrap());
33
34/// Format sent by Unity on iPadOS
35static OS_IPADOS_REGEX: Lazy<Regex> =
36    Lazy::new(|| Regex::new(r"^iPadOS (?P<version>\d+\.\d+\.\d+)").unwrap());
37
38/// Specific regex to parse Linux distros
39static OS_LINUX_DISTRO_UNAME_REGEX: Lazy<Regex> = Lazy::new(|| {
40    Regex::new(r"^Linux (?P<kernel_version>\d+\.\d+(\.\d+(\.[1-9]+)?)?) (?P<name>[a-zA-Z]+) (?P<version>\d+(\.\d+){0,2})").unwrap()
41});
42
43/// Environment.OSVersion or RuntimeInformation.OSDescription (uname) on Mono and CoreCLR on
44/// macOS, iOS, Linux, etc.
45static OS_UNAME_REGEX: Lazy<Regex> = Lazy::new(|| {
46    Regex::new(r"^(?P<name>[a-zA-Z]+) (?P<kernel_version>\d+\.\d+(\.\d+(\.[1-9]+)?)?)").unwrap()
47});
48
49/// Mono 5.4, .NET Core 2.0
50static RUNTIME_DOTNET_REGEX: Lazy<Regex> =
51    Lazy::new(|| Regex::new(r"^(?P<name>.*) (?P<version>\d+\.\d+(\.\d+){0,2}).*$").unwrap());
52
53/// A hashmap that translates from the android model to the more human-friendly product-names.
54/// E.g. NE2211 -> OnePlus 10 Pro 5G
55static ANDROID_MODEL_NAMES: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
56    let mut map = HashMap::new();
57    // Note that windows paths with backslashes '\' won't work on unix systems.
58    let android_str = include_str!("android_models.csv");
59
60    let mut lines = android_str.lines();
61
62    let header = lines.next().expect("CSV file should have a header");
63
64    let header_fields: Vec<&str> = header.split(',').collect();
65    let model_index = header_fields.iter().position(|&s| s.trim() == "Model");
66    let product_name_index = header_fields
67        .iter()
68        .position(|&s| s.trim() == "Marketing Name");
69
70    let (model_index, product_name_index) = match (model_index, product_name_index) {
71        (Some(model_index), Some(product_name_index)) => (model_index, product_name_index),
72        (_, _) => {
73            relay_log::error!(
74                "failed to find model and/or marketing name headers for android-model map",
75            );
76
77            return HashMap::new();
78        }
79    };
80
81    for line in lines {
82        let fields: Vec<&str> = line.split(',').collect();
83        if fields.len() > std::cmp::max(model_index, product_name_index) {
84            map.insert(
85                fields[model_index].trim(),
86                fields[product_name_index].trim(),
87            );
88        }
89    }
90    map
91});
92
93fn normalize_runtime_context(runtime: &mut RuntimeContext) {
94    if runtime.name.value().is_empty() && runtime.version.value().is_empty() {
95        if let Some(raw_description) = runtime.raw_description.as_str() {
96            if let Some(captures) = RUNTIME_DOTNET_REGEX.captures(raw_description) {
97                runtime.name = captures.name("name").map(|m| m.as_str().to_owned()).into();
98                runtime.version = captures
99                    .name("version")
100                    .map(|m| m.as_str().to_owned())
101                    .into();
102            }
103        }
104    }
105
106    // RuntimeInformation.FrameworkDescription doesn't return a very useful value.
107    // Example: ".NET Framework 4.7.3056.0"
108    // Use release keys from registry sent as #build
109    if let Some(name) = runtime.name.as_str() {
110        if let Some(build) = runtime.build.as_str() {
111            if name.starts_with(".NET Framework") {
112                let version = match build {
113                    "378389" => Some("4.5".to_owned()),
114                    "378675" => Some("4.5.1".to_owned()),
115                    "378758" => Some("4.5.1".to_owned()),
116                    "379893" => Some("4.5.2".to_owned()),
117                    "393295" => Some("4.6".to_owned()),
118                    "393297" => Some("4.6".to_owned()),
119                    "394254" => Some("4.6.1".to_owned()),
120                    "394271" => Some("4.6.1".to_owned()),
121                    "394802" => Some("4.6.2".to_owned()),
122                    "394806" => Some("4.6.2".to_owned()),
123                    "460798" => Some("4.7".to_owned()),
124                    "460805" => Some("4.7".to_owned()),
125                    "461308" => Some("4.7.1".to_owned()),
126                    "461310" => Some("4.7.1".to_owned()),
127                    "461808" => Some("4.7.2".to_owned()),
128                    "461814" => Some("4.7.2".to_owned()),
129                    "528040" => Some("4.8".to_owned()),
130                    "528049" => Some("4.8".to_owned()),
131                    "528209" => Some("4.8".to_owned()),
132                    "528372" => Some("4.8".to_owned()),
133                    "528449" => Some("4.8".to_owned()),
134                    _ => None,
135                };
136
137                if let Some(version) = version {
138                    runtime.version = version.into();
139                }
140            }
141        }
142    }
143
144    // Calculation of the computed context for the runtime.
145    // The equivalent calculation is done in `sentry` in `src/sentry/interfaces/contexts.py`.
146    if runtime.runtime.value().is_none() {
147        if let (Some(name), Some(version)) = (runtime.name.value(), runtime.version.value()) {
148            runtime.runtime = Annotated::from(format!("{name} {version}"));
149        }
150    }
151}
152
153/// Parses the Windows build number from the description and maps it to a marketing name.
154/// Source: <https://en.wikipedia.org/wiki/List_of_Microsoft_Windows_versions>.
155/// Note: We cannot distinguish between Windows Server and PC versions, so we map to the PC versions
156/// here.
157fn get_windows_version(description: &str) -> Option<(&str, &str)> {
158    let captures = OS_WINDOWS_REGEX1
159        .captures(description)
160        .or_else(|| OS_WINDOWS_REGEX2.captures(description))?;
161
162    let full_version = captures.name("version")?.as_str();
163    let build_number_str = captures.name("build_number")?.as_str();
164    let build_number = build_number_str.parse::<u64>().ok()?;
165
166    let version_name = match build_number {
167        // Not considering versions below Windows XP
168        2600..=3790 => "XP",
169        6002 => "Vista",
170        7601 => "7",
171        9200 => "8",
172        9600 => "8.1",
173        10240..=19044 => "10",
174        22000..=22999 => "11",
175        // Fall back to raw version:
176        _ => full_version,
177    };
178
179    Some((version_name, build_number_str))
180}
181
182/// Simple marketing names in the form `<OS> <version>`.
183fn get_marketing_name(description: &str) -> Option<(&str, &str)> {
184    let (name, version) = description.split_once(' ')?;
185    let name = name.trim();
186    let version = version.trim();
187
188    // Validate if it looks like a reasonable name.
189    if name.bytes().any(|c| !c.is_ascii_alphabetic()) {
190        return None;
191    }
192
193    // Validate if it looks like a reasonable version.
194    if version
195        .bytes()
196        .any(|c| !matches!(c, b'0'..=b'9' | b'.' | b'-'))
197    {
198        return None;
199    }
200
201    Some((name, version))
202}
203
204#[allow(dead_code)]
205/// Returns the API version of an Android description.
206///
207/// TODO use this to add a tag `android.api` to the message (not yet 100% decided)
208pub fn get_android_api_version(description: &str) -> Option<&str> {
209    if let Some(captures) = OS_ANDROID_REGEX.captures(description) {
210        captures.name("api").map(|m| m.as_str())
211    } else {
212        None
213    }
214}
215
216fn normalize_os_context(os: &mut OsContext) {
217    if os.name.value().is_some() || os.version.value().is_some() {
218        compute_os_context(os);
219        return;
220    }
221
222    if let Some(raw_description) = os.raw_description.as_str() {
223        if let Some((version, build_number)) = get_windows_version(raw_description) {
224            os.name = "Windows".to_owned().into();
225            os.version = version.to_owned().into();
226            if os.build.is_empty() {
227                // Keep raw version as build
228                os.build.set_value(Some(build_number.to_owned().into()));
229            }
230        } else if let Some(captures) = OS_MACOS_REGEX.captures(raw_description) {
231            os.name = "macOS".to_owned().into();
232            os.version = captures
233                .name("version")
234                .map(|m| m.as_str().to_owned())
235                .into();
236            os.build = captures
237                .name("build")
238                .map(|m| m.as_str().to_owned().into())
239                .into();
240        } else if let Some(captures) = OS_IOS_REGEX.captures(raw_description) {
241            os.name = "iOS".to_owned().into();
242            os.version = captures
243                .name("version")
244                .map(|m| m.as_str().to_owned())
245                .into();
246        } else if let Some(captures) = OS_IPADOS_REGEX.captures(raw_description) {
247            os.name = "iPadOS".to_owned().into();
248            os.version = captures
249                .name("version")
250                .map(|m| m.as_str().to_owned())
251                .into();
252        } else if let Some(captures) = OS_LINUX_DISTRO_UNAME_REGEX.captures(raw_description) {
253            os.name = captures.name("name").map(|m| m.as_str().to_owned()).into();
254            os.version = captures
255                .name("version")
256                .map(|m| m.as_str().to_owned())
257                .into();
258            os.kernel_version = captures
259                .name("kernel_version")
260                .map(|m| m.as_str().to_owned())
261                .into();
262        } else if let Some(captures) = OS_UNAME_REGEX.captures(raw_description) {
263            os.name = captures.name("name").map(|m| m.as_str().to_owned()).into();
264            os.kernel_version = captures
265                .name("kernel_version")
266                .map(|m| m.as_str().to_owned())
267                .into();
268        } else if let Some(captures) = OS_ANDROID_REGEX.captures(raw_description) {
269            os.name = "Android".to_owned().into();
270            os.version = captures
271                .name("version")
272                .map(|m| m.as_str().to_owned())
273                .into();
274        } else if raw_description == "Nintendo Switch" {
275            os.name = "Nintendo OS".to_owned().into();
276        } else if let Some((name, version)) = get_marketing_name(raw_description) {
277            os.name = name.to_owned().into();
278            os.version = version.to_owned().into();
279        }
280    }
281
282    compute_os_context(os);
283}
284
285fn compute_os_context(os: &mut OsContext) {
286    // Calculation of the computed context for the os.
287    // The equivalent calculation is done in `sentry` in `src/sentry/interfaces/contexts.py`.
288    if os.os.value().is_none() {
289        if let (Some(name), Some(version)) = (os.name.value(), os.version.value()) {
290            os.os = Annotated::from(format!("{name} {version}"));
291        }
292    }
293}
294
295fn normalize_browser_context(browser: &mut BrowserContext) {
296    // Calculation of the computed context for the browser.
297    // The equivalent calculation is done in `sentry` in `src/sentry/interfaces/contexts.py`.
298    if browser.browser.value().is_none() {
299        if let (Some(name), Some(version)) = (browser.name.value(), browser.version.value()) {
300            browser.browser = Annotated::from(format!("{name} {version}"));
301        }
302    }
303}
304
305fn parse_raw_response_data(response: &ResponseContext) -> Option<(&'static str, Value)> {
306    let raw = response.data.as_str()?;
307
308    serde_json::from_str(raw)
309        .ok()
310        .map(|value| ("application/json", value))
311}
312
313fn normalize_response_data(response: &mut ResponseContext) {
314    // Always derive the `inferred_content_type` from the response body, even if there is a
315    // `Content-Type` header present. This value can technically be ingested (due to the schema) but
316    // should always be overwritten in normalization. Only if inference fails, fall back to the
317    // content type header.
318    if let Some((content_type, parsed_data)) = parse_raw_response_data(response) {
319        // Retain meta data on the body (e.g. trimming annotations) but remove anything on the
320        // inferred content type.
321        response.data.set_value(Some(parsed_data));
322        response.inferred_content_type = Annotated::from(content_type.to_owned());
323    } else {
324        response.inferred_content_type = response
325            .headers
326            .value()
327            .and_then(|headers| headers.get_header("Content-Type"))
328            .map(|value| value.split(';').next().unwrap_or(value).to_owned())
329            .into();
330    }
331}
332
333fn normalize_response(response: &mut ResponseContext) {
334    normalize_response_data(response);
335
336    let headers = match response.headers.value_mut() {
337        Some(headers) => headers,
338        None => return,
339    };
340
341    if response.cookies.value().is_some() {
342        headers.remove("Set-Cookie");
343        return;
344    }
345
346    let cookie_header = match headers.get_header("Set-Cookie") {
347        Some(header) => header,
348        None => return,
349    };
350
351    if let Ok(new_cookies) = Cookies::parse(cookie_header) {
352        response.cookies = Annotated::from(new_cookies);
353        headers.remove("Set-Cookie");
354    }
355}
356
357/// Normalizes the given context.
358pub fn normalize_context(context: &mut Context) {
359    match context {
360        Context::Runtime(runtime) => normalize_runtime_context(runtime),
361        Context::Os(os) => normalize_os_context(os),
362        Context::Browser(browser) => normalize_browser_context(browser),
363        Context::Response(response) => normalize_response(response),
364        Context::Device(device) => {
365            if let Some(product_name) = device
366                .as_ref()
367                .model
368                .value()
369                .and_then(|model| ANDROID_MODEL_NAMES.get(model.as_str()))
370            {
371                device.name.set_value(Some(product_name.to_string()))
372            }
373        }
374        _ => {}
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use relay_event_schema::protocol::{Headers, LenientString, PairList};
381    use relay_protocol::Object;
382    use similar_asserts::assert_eq;
383
384    use super::*;
385
386    #[test]
387    fn test_get_product_name() {
388        assert_eq!(
389            ANDROID_MODEL_NAMES.get("NE2211").unwrap(),
390            &"OnePlus 10 Pro 5G"
391        );
392
393        assert_eq!(
394            ANDROID_MODEL_NAMES.get("MP04").unwrap(),
395            &"A13 Pro Max 5G EEA"
396        );
397
398        assert_eq!(ANDROID_MODEL_NAMES.get("ZT216_7").unwrap(), &"zyrex");
399
400        assert!(ANDROID_MODEL_NAMES.get("foobar").is_none());
401    }
402
403    #[test]
404    fn test_dotnet_framework_48_without_build_id() {
405        let mut runtime = RuntimeContext {
406            raw_description: ".NET Framework 4.8.4250.0".to_owned().into(),
407            ..RuntimeContext::default()
408        };
409
410        normalize_runtime_context(&mut runtime);
411        assert_eq!(Some(".NET Framework"), runtime.name.as_str());
412        assert_eq!(Some("4.8.4250.0"), runtime.version.as_str());
413    }
414
415    #[test]
416    fn test_dotnet_framework_472() {
417        let mut runtime = RuntimeContext {
418            raw_description: ".NET Framework 4.7.3056.0".to_owned().into(),
419            build: LenientString("461814".to_owned()).into(),
420            ..RuntimeContext::default()
421        };
422
423        normalize_runtime_context(&mut runtime);
424        assert_eq!(Some(".NET Framework"), runtime.name.as_str());
425        assert_eq!(Some("4.7.2"), runtime.version.as_str());
426    }
427
428    #[test]
429    fn test_dotnet_framework_future_version() {
430        let mut runtime = RuntimeContext {
431            raw_description: ".NET Framework 200.0".to_owned().into(),
432            build: LenientString("999999".to_owned()).into(),
433            ..RuntimeContext::default()
434        };
435
436        // Unmapped build number doesn't override version
437        normalize_runtime_context(&mut runtime);
438        assert_eq!(Some(".NET Framework"), runtime.name.as_str());
439        assert_eq!(Some("200.0"), runtime.version.as_str());
440    }
441
442    #[test]
443    fn test_dotnet_native() {
444        let mut runtime = RuntimeContext {
445            raw_description: ".NET Native 2.0".to_owned().into(),
446            ..RuntimeContext::default()
447        };
448
449        normalize_runtime_context(&mut runtime);
450        assert_eq!(Some(".NET Native"), runtime.name.as_str());
451        assert_eq!(Some("2.0"), runtime.version.as_str());
452    }
453
454    #[test]
455    fn test_dotnet_core() {
456        let mut runtime = RuntimeContext {
457            raw_description: ".NET Core 2.0".to_owned().into(),
458            ..RuntimeContext::default()
459        };
460
461        normalize_runtime_context(&mut runtime);
462        assert_eq!(Some(".NET Core"), runtime.name.as_str());
463        assert_eq!(Some("2.0"), runtime.version.as_str());
464    }
465
466    #[test]
467    fn test_windows_7_or_server_2008() {
468        // Environment.OSVersion on Windows 7 (CoreCLR 1.0+, .NET Framework 1.1+, Mono 1+)
469        let mut os = OsContext {
470            raw_description: "Microsoft Windows NT 6.1.7601 Service Pack 1"
471                .to_owned()
472                .into(),
473            ..OsContext::default()
474        };
475
476        normalize_os_context(&mut os);
477        assert_eq!(Some("Windows"), os.name.as_str());
478        assert_eq!(Some("7"), os.version.as_str());
479    }
480
481    #[test]
482    fn test_windows_8_or_server_2012_or_later() {
483        // Environment.OSVersion on Windows 10 (CoreCLR 1.0+, .NET Framework 1.1+, Mono 1+)
484        // *or later, due to GetVersionEx deprecated on Windows 8.1
485        // It's a potentially really misleading API on newer platforms
486        // Only used if RuntimeInformation.OSDescription is not available (old runtimes)
487        let mut os = OsContext {
488            raw_description: "Microsoft Windows NT 6.2.9200.0".to_owned().into(),
489            ..OsContext::default()
490        };
491
492        normalize_os_context(&mut os);
493        assert_eq!(Some("Windows"), os.name.as_str());
494        assert_eq!(Some("8"), os.version.as_str());
495    }
496
497    #[test]
498    fn test_windows_10() {
499        // RuntimeInformation.OSDescription on Windows 10 (CoreCLR 2.0+, .NET
500        // Framework 4.7.1+, Mono 5.4+)
501        let mut os = OsContext {
502            raw_description: "Microsoft Windows 10.0.16299".to_owned().into(),
503            ..OsContext::default()
504        };
505
506        normalize_os_context(&mut os);
507        assert_eq!(Some("Windows"), os.name.as_str());
508        assert_eq!(Some("10"), os.version.as_str());
509    }
510
511    #[test]
512    fn test_windows_11() {
513        // https://github.com/getsentry/relay/issues/1201
514        let mut os = OsContext {
515            raw_description: "Microsoft Windows 10.0.22000".to_owned().into(),
516            ..OsContext::default()
517        };
518
519        normalize_os_context(&mut os);
520        assert_eq!(Some("Windows"), os.name.as_str());
521        assert_eq!(Some("11"), os.version.as_str());
522    }
523
524    #[test]
525    fn test_windows_11_future1() {
526        // This is fictional as of today, but let's be explicit about the behavior we expect.
527        let mut os = OsContext {
528            raw_description: "Microsoft Windows 10.0.22001".to_owned().into(),
529            ..OsContext::default()
530        };
531
532        normalize_os_context(&mut os);
533        assert_eq!(Some("Windows"), os.name.as_str());
534        assert_eq!(Some("11"), os.version.as_str());
535    }
536
537    #[test]
538    fn test_windows_11_future2() {
539        // This is fictional, but let's be explicit about the behavior we expect.
540        let mut os = OsContext {
541            raw_description: "Microsoft Windows 10.1.23456".to_owned().into(),
542            ..OsContext::default()
543        };
544
545        normalize_os_context(&mut os);
546        assert_eq!(Some("Windows"), os.name.as_str());
547        assert_eq!(Some("10.1.23456"), os.version.as_str());
548    }
549
550    #[test]
551    fn test_macos_os_version() {
552        // Environment.OSVersion on macOS (CoreCLR 1.0+, Mono 1+)
553        let mut os = OsContext {
554            raw_description: "Unix 17.5.0.0".to_owned().into(),
555            ..OsContext::default()
556        };
557
558        normalize_os_context(&mut os);
559        assert_eq!(Some("Unix"), os.name.as_str());
560        assert_eq!(Some("17.5.0"), os.kernel_version.as_str());
561    }
562
563    #[test]
564    fn test_macos_runtime() {
565        // RuntimeInformation.OSDescription on macOS (CoreCLR 2.0+, Mono 5.4+)
566        let mut os = OsContext {
567        raw_description: "Darwin 17.5.0 Darwin Kernel Version 17.5.0: Mon Mar  5 22:24:32 PST 2018; root:xnu-4570.51.1~1/RELEASE_X86_64".to_owned().into(),
568        ..OsContext::default()
569    };
570
571        normalize_os_context(&mut os);
572        assert_eq!(Some("Darwin"), os.name.as_str());
573        assert_eq!(Some("17.5.0"), os.kernel_version.as_str());
574    }
575
576    #[test]
577    fn test_centos_os_version() {
578        // Environment.OSVersion on CentOS 7 (CoreCLR 1.0+, Mono 1+)
579        let mut os = OsContext {
580            raw_description: "Unix 3.10.0.693".to_owned().into(),
581            ..OsContext::default()
582        };
583
584        normalize_os_context(&mut os);
585        assert_eq!(Some("Unix"), os.name.as_str());
586        assert_eq!(Some("3.10.0.693"), os.kernel_version.as_str());
587    }
588
589    #[test]
590    fn test_centos_runtime_info() {
591        // RuntimeInformation.OSDescription on CentOS 7 (CoreCLR 2.0+, Mono 5.4+)
592        let mut os = OsContext {
593            raw_description: "Linux 3.10.0-693.21.1.el7.x86_64 #1 SMP Wed Mar 7 19:03:37 UTC 2018"
594                .to_owned()
595                .into(),
596            ..OsContext::default()
597        };
598
599        normalize_os_context(&mut os);
600        assert_eq!(Some("Linux"), os.name.as_str());
601        assert_eq!(Some("3.10.0"), os.kernel_version.as_str());
602    }
603
604    #[test]
605    fn test_wsl_ubuntu() {
606        // RuntimeInformation.OSDescription on Windows Subsystem for Linux (Ubuntu)
607        // (CoreCLR 2.0+, Mono 5.4+)
608        let mut os = OsContext {
609            raw_description: "Linux 4.4.0-43-Microsoft #1-Microsoft Wed Dec 31 14:42:53 PST 2014"
610                .to_owned()
611                .into(),
612            ..OsContext::default()
613        };
614
615        normalize_os_context(&mut os);
616        assert_eq!(Some("Linux"), os.name.as_str());
617        assert_eq!(Some("4.4.0"), os.kernel_version.as_str());
618    }
619
620    #[test]
621    fn test_macos_with_build() {
622        let mut os = OsContext {
623            raw_description: "Mac OS X 10.14.2 (18C54)".to_owned().into(),
624            ..OsContext::default()
625        };
626
627        normalize_os_context(&mut os);
628        assert_eq!(Some("macOS"), os.name.as_str());
629        assert_eq!(Some("10.14.2"), os.version.as_str());
630        assert_eq!(Some("18C54"), os.build.as_str());
631    }
632
633    #[test]
634    fn test_macos_without_build() {
635        let mut os = OsContext {
636            raw_description: "Mac OS X 10.14.2".to_owned().into(),
637            ..OsContext::default()
638        };
639
640        normalize_os_context(&mut os);
641        assert_eq!(Some("macOS"), os.name.as_str());
642        assert_eq!(Some("10.14.2"), os.version.as_str());
643        assert_eq!(None, os.build.value());
644    }
645
646    #[test]
647    fn test_name_not_overwritten() {
648        let mut os = OsContext {
649            name: "Properly defined name".to_owned().into(),
650            raw_description: "Linux 4.4.0".to_owned().into(),
651            ..OsContext::default()
652        };
653
654        normalize_os_context(&mut os);
655        assert_eq!(Some("Properly defined name"), os.name.as_str());
656    }
657
658    #[test]
659    fn test_version_not_overwritten() {
660        let mut os = OsContext {
661            version: "Properly defined version".to_owned().into(),
662            raw_description: "Linux 4.4.0".to_owned().into(),
663            ..OsContext::default()
664        };
665
666        normalize_os_context(&mut os);
667        assert_eq!(Some("Properly defined version"), os.version.as_str());
668    }
669
670    #[test]
671    fn test_no_name() {
672        let mut os = OsContext::default();
673
674        normalize_os_context(&mut os);
675        assert_eq!(None, os.name.value());
676        assert_eq!(None, os.version.value());
677        assert_eq!(None, os.kernel_version.value());
678        assert_eq!(None, os.raw_description.value());
679    }
680
681    #[test]
682    fn test_unity_mac_os() {
683        let mut os = OsContext {
684            raw_description: "Mac OS X 10.16.0".to_owned().into(),
685            ..OsContext::default()
686        };
687        normalize_os_context(&mut os);
688        assert_eq!(Some("macOS"), os.name.as_str());
689        assert_eq!(Some("10.16.0"), os.version.as_str());
690        assert_eq!(None, os.build.value());
691    }
692
693    #[test]
694    fn test_unity_ios() {
695        let mut os = OsContext {
696            raw_description: "iOS 17.5.1".to_owned().into(),
697            ..OsContext::default()
698        };
699
700        normalize_os_context(&mut os);
701        assert_eq!(Some("iOS"), os.name.as_str());
702        assert_eq!(Some("17.5.1"), os.version.as_str());
703        assert_eq!(None, os.build.value());
704    }
705
706    #[test]
707    fn test_unity_ipados() {
708        let mut os = OsContext {
709            raw_description: "iPadOS 17.5.1".to_owned().into(),
710            ..OsContext::default()
711        };
712
713        normalize_os_context(&mut os);
714        assert_eq!(Some("iPadOS"), os.name.as_str());
715        assert_eq!(Some("17.5.1"), os.version.as_str());
716        assert_eq!(None, os.build.value());
717    }
718
719    //OS_WINDOWS_REGEX = r#"^(Microsoft )?Windows (NT )?(?P<version>\d+\.\d+\.\d+).*$"#;
720    #[test]
721    fn test_unity_windows_os() {
722        let mut os = OsContext {
723            raw_description: "Windows 10  (10.0.19042) 64bit".to_owned().into(),
724            ..OsContext::default()
725        };
726        normalize_os_context(&mut os);
727        assert_eq!(Some("Windows"), os.name.as_str());
728        assert_eq!(Some("10"), os.version.as_str());
729        assert_eq!(Some(&LenientString("19042".to_owned())), os.build.value());
730    }
731
732    #[test]
733    fn test_unity_android_os() {
734        let mut os = OsContext {
735            raw_description: "Android OS 11 / API-30 (RP1A.201005.001/2107031736)"
736                .to_owned()
737                .into(),
738            ..OsContext::default()
739        };
740        normalize_os_context(&mut os);
741        assert_eq!(Some("Android"), os.name.as_str());
742        assert_eq!(Some("11"), os.version.as_str());
743        assert_eq!(None, os.build.value());
744    }
745
746    #[test]
747    fn test_unity_android_api_version() {
748        let description = "Android OS 11 / API-30 (RP1A.201005.001/2107031736)";
749        assert_eq!(Some("30"), get_android_api_version(description));
750    }
751
752    #[test]
753    fn test_unreal_windows_os() {
754        let mut os = OsContext {
755            raw_description: "Windows 10".to_owned().into(),
756            ..OsContext::default()
757        };
758        normalize_os_context(&mut os);
759        assert_eq!(Some("Windows"), os.name.as_str());
760        assert_eq!(Some("10"), os.version.as_str());
761    }
762
763    #[test]
764    fn test_linux_5_11() {
765        let mut os = OsContext {
766            raw_description: "Linux 5.11 Ubuntu 20.04 64bit".to_owned().into(),
767            ..OsContext::default()
768        };
769        normalize_os_context(&mut os);
770        assert_eq!(Some("Ubuntu"), os.name.as_str());
771        assert_eq!(Some("20.04"), os.version.as_str());
772        assert_eq!(Some("5.11"), os.kernel_version.as_str());
773        assert_eq!(None, os.build.value());
774    }
775
776    #[test]
777    fn test_unity_nintendo_switch() {
778        // Format sent by Unity on Nintendo Switch
779        let mut os = OsContext {
780            raw_description: "Nintendo Switch".to_owned().into(),
781            ..OsContext::default()
782        };
783
784        normalize_os_context(&mut os);
785        assert_eq!(Some("Nintendo OS"), os.name.as_str());
786        assert_eq!(None, os.version.value());
787        assert_eq!(None, os.build.value());
788    }
789
790    #[test]
791    fn test_android_4_4_2() {
792        let mut os = OsContext {
793            raw_description: "Android OS 4.4.2 / API-19 (KOT49H/A536_S186_150813_ROW)"
794                .to_owned()
795                .into(),
796            ..OsContext::default()
797        };
798        normalize_os_context(&mut os);
799        assert_eq!(Some("Android"), os.name.as_str());
800        assert_eq!(Some("4.4.2"), os.version.as_str());
801        assert_eq!(None, os.build.value());
802    }
803
804    #[test]
805    fn test_infer_json() {
806        let mut response = ResponseContext {
807            data: Annotated::from(Value::String(r#"{"foo":"bar"}"#.to_owned())),
808            ..ResponseContext::default()
809        };
810
811        let mut expected_value = Object::new();
812        expected_value.insert(
813            "foo".to_owned(),
814            Annotated::from(Value::String("bar".into())),
815        );
816
817        normalize_response(&mut response);
818        assert_eq!(
819            response.inferred_content_type.as_str(),
820            Some("application/json")
821        );
822        assert_eq!(response.data.value(), Some(&Value::Object(expected_value)));
823    }
824
825    #[test]
826    fn test_broken_json_with_fallback() {
827        let mut response = ResponseContext {
828            data: Annotated::from(Value::String(r#"{"foo":"b"#.to_owned())),
829            headers: Annotated::from(Headers(PairList(vec![Annotated::new((
830                Annotated::new("Content-Type".to_owned().into()),
831                Annotated::new("text/plain; encoding=utf-8".to_owned().into()),
832            ))]))),
833            ..ResponseContext::default()
834        };
835
836        normalize_response(&mut response);
837        assert_eq!(response.inferred_content_type.as_str(), Some("text/plain"));
838        assert_eq!(response.data.as_str(), Some(r#"{"foo":"b"#));
839    }
840
841    #[test]
842    fn test_broken_json_without_fallback() {
843        let mut response = ResponseContext {
844            data: Annotated::from(Value::String(r#"{"foo":"b"#.to_owned())),
845            ..ResponseContext::default()
846        };
847
848        normalize_response(&mut response);
849        assert_eq!(response.inferred_content_type.value(), None);
850        assert_eq!(response.data.as_str(), Some(r#"{"foo":"b"#));
851    }
852
853    #[test]
854    fn test_os_computed_context() {
855        let mut os = OsContext {
856            name: "Windows".to_owned().into(),
857            version: "10".to_owned().into(),
858            ..OsContext::default()
859        };
860
861        normalize_os_context(&mut os);
862        assert_eq!(Some("Windows 10"), os.os.as_str());
863    }
864
865    #[test]
866    fn test_os_computed_context_missing_version() {
867        let mut os = OsContext {
868            name: "Windows".to_owned().into(),
869            ..OsContext::default()
870        };
871
872        normalize_os_context(&mut os);
873        assert_eq!(None, os.os.value());
874    }
875
876    #[test]
877    fn test_runtime_computed_context() {
878        let mut runtime = RuntimeContext {
879            name: "Python".to_owned().into(),
880            version: "3.9.0".to_owned().into(),
881            ..RuntimeContext::default()
882        };
883
884        normalize_runtime_context(&mut runtime);
885        assert_eq!(Some("Python 3.9.0"), runtime.runtime.as_str());
886    }
887
888    #[test]
889    fn test_runtime_computed_context_missing_version() {
890        let mut runtime = RuntimeContext {
891            name: "Python".to_owned().into(),
892            ..RuntimeContext::default()
893        };
894
895        normalize_runtime_context(&mut runtime);
896        assert_eq!(None, runtime.runtime.value());
897    }
898
899    #[test]
900    fn test_browser_computed_context() {
901        let mut browser = BrowserContext {
902            name: "Firefox".to_owned().into(),
903            version: "89.0".to_owned().into(),
904            ..BrowserContext::default()
905        };
906
907        normalize_browser_context(&mut browser);
908        assert_eq!(Some("Firefox 89.0"), browser.browser.as_str());
909    }
910
911    #[test]
912    fn test_browser_computed_context_missing_version() {
913        let mut browser = BrowserContext {
914            name: "Firefox".to_owned().into(),
915            ..BrowserContext::default()
916        };
917
918        normalize_browser_context(&mut browser);
919        assert_eq!(None, browser.browser.value());
920    }
921}