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()
95        && runtime.version.value().is_empty()
96        && let Some(raw_description) = runtime.raw_description.as_str()
97        && let Some(captures) = RUNTIME_DOTNET_REGEX.captures(raw_description)
98    {
99        runtime.name = captures.name("name").map(|m| m.as_str().to_owned()).into();
100        runtime.version = captures
101            .name("version")
102            .map(|m| m.as_str().to_owned())
103            .into();
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        && let Some(build) = runtime.build.as_str()
111        && name.starts_with(".NET Framework")
112    {
113        let version = match build {
114            "378389" => Some("4.5".to_owned()),
115            "378675" => Some("4.5.1".to_owned()),
116            "378758" => Some("4.5.1".to_owned()),
117            "379893" => Some("4.5.2".to_owned()),
118            "393295" => Some("4.6".to_owned()),
119            "393297" => Some("4.6".to_owned()),
120            "394254" => Some("4.6.1".to_owned()),
121            "394271" => Some("4.6.1".to_owned()),
122            "394802" => Some("4.6.2".to_owned()),
123            "394806" => Some("4.6.2".to_owned()),
124            "460798" => Some("4.7".to_owned()),
125            "460805" => Some("4.7".to_owned()),
126            "461308" => Some("4.7.1".to_owned()),
127            "461310" => Some("4.7.1".to_owned()),
128            "461808" => Some("4.7.2".to_owned()),
129            "461814" => Some("4.7.2".to_owned()),
130            "528040" => Some("4.8".to_owned()),
131            "528049" => Some("4.8".to_owned()),
132            "528209" => Some("4.8".to_owned()),
133            "528372" => Some("4.8".to_owned()),
134            "528449" => Some("4.8".to_owned()),
135            _ => None,
136        };
137
138        if let Some(version) = version {
139            runtime.version = version.into();
140        }
141    }
142
143    // Calculation of the computed context for the runtime.
144    // The equivalent calculation is done in `sentry` in `src/sentry/interfaces/contexts.py`.
145    if runtime.runtime.value().is_none()
146        && let (Some(name), Some(version)) = (runtime.name.value(), runtime.version.value())
147    {
148        runtime.runtime = Annotated::from(format!("{name} {version}"));
149    }
150}
151
152/// Parses the Windows build number from the description and maps it to a marketing name.
153/// Source: <https://en.wikipedia.org/wiki/List_of_Microsoft_Windows_versions>.
154/// Note: We cannot distinguish between Windows Server and PC versions, so we map to the PC versions
155/// here.
156fn get_windows_version(description: &str) -> Option<(&str, &str)> {
157    let captures = OS_WINDOWS_REGEX1
158        .captures(description)
159        .or_else(|| OS_WINDOWS_REGEX2.captures(description))?;
160
161    let full_version = captures.name("version")?.as_str();
162    let build_number_str = captures.name("build_number")?.as_str();
163    let build_number = build_number_str.parse::<u64>().ok()?;
164
165    let version_name = match build_number {
166        // Not considering versions below Windows XP
167        2600..=3790 => "XP",
168        6002 => "Vista",
169        7601 => "7",
170        9200 => "8",
171        9600 => "8.1",
172        10240..=19044 => "10",
173        22000..=22999 => "11",
174        // Fall back to raw version:
175        _ => full_version,
176    };
177
178    Some((version_name, build_number_str))
179}
180
181/// Simple marketing names in the form `<OS> <version>`.
182fn get_marketing_name(description: &str) -> Option<(&str, &str)> {
183    let (name, version) = description.split_once(' ')?;
184    let name = name.trim();
185    let version = version.trim();
186
187    // Validate if it looks like a reasonable name.
188    if name.bytes().any(|c| !c.is_ascii_alphabetic()) {
189        return None;
190    }
191
192    // Validate if it looks like a reasonable version.
193    if version
194        .bytes()
195        .any(|c| !matches!(c, b'0'..=b'9' | b'.' | b'-'))
196    {
197        return None;
198    }
199
200    Some((name, version))
201}
202
203#[allow(dead_code)]
204/// Returns the API version of an Android description.
205///
206/// TODO use this to add a tag `android.api` to the message (not yet 100% decided)
207pub fn get_android_api_version(description: &str) -> Option<&str> {
208    if let Some(captures) = OS_ANDROID_REGEX.captures(description) {
209        captures.name("api").map(|m| m.as_str())
210    } else {
211        None
212    }
213}
214
215fn normalize_os_context(os: &mut OsContext) {
216    if os.name.value().is_some() || os.version.value().is_some() {
217        compute_os_context(os);
218        return;
219    }
220
221    if let Some(raw_description) = os.raw_description.as_str() {
222        if let Some((version, build_number)) = get_windows_version(raw_description) {
223            os.name = "Windows".to_owned().into();
224            os.version = version.to_owned().into();
225            if os.build.is_empty() {
226                // Keep raw version as build
227                os.build.set_value(Some(build_number.to_owned().into()));
228            }
229        } else if let Some(captures) = OS_MACOS_REGEX.captures(raw_description) {
230            os.name = "macOS".to_owned().into();
231            os.version = captures
232                .name("version")
233                .map(|m| m.as_str().to_owned())
234                .into();
235            os.build = captures
236                .name("build")
237                .map(|m| m.as_str().to_owned().into())
238                .into();
239        } else if let Some(captures) = OS_IOS_REGEX.captures(raw_description) {
240            os.name = "iOS".to_owned().into();
241            os.version = captures
242                .name("version")
243                .map(|m| m.as_str().to_owned())
244                .into();
245        } else if let Some(captures) = OS_IPADOS_REGEX.captures(raw_description) {
246            os.name = "iPadOS".to_owned().into();
247            os.version = captures
248                .name("version")
249                .map(|m| m.as_str().to_owned())
250                .into();
251        } else if let Some(captures) = OS_LINUX_DISTRO_UNAME_REGEX.captures(raw_description) {
252            os.name = captures.name("name").map(|m| m.as_str().to_owned()).into();
253            os.version = captures
254                .name("version")
255                .map(|m| m.as_str().to_owned())
256                .into();
257            os.kernel_version = captures
258                .name("kernel_version")
259                .map(|m| m.as_str().to_owned())
260                .into();
261        } else if let Some(captures) = OS_UNAME_REGEX.captures(raw_description) {
262            os.name = captures.name("name").map(|m| m.as_str().to_owned()).into();
263            os.kernel_version = captures
264                .name("kernel_version")
265                .map(|m| m.as_str().to_owned())
266                .into();
267        } else if let Some(captures) = OS_ANDROID_REGEX.captures(raw_description) {
268            os.name = "Android".to_owned().into();
269            os.version = captures
270                .name("version")
271                .map(|m| m.as_str().to_owned())
272                .into();
273        } else if raw_description == "Nintendo Switch" {
274            os.name = "Nintendo OS".to_owned().into();
275        } else if let Some((name, version)) = get_marketing_name(raw_description) {
276            os.name = name.to_owned().into();
277            os.version = version.to_owned().into();
278        }
279    }
280
281    compute_os_context(os);
282}
283
284fn compute_os_context(os: &mut OsContext) {
285    // Calculation of the computed context for the os.
286    // The equivalent calculation is done in `sentry` in `src/sentry/interfaces/contexts.py`.
287    if os.os.value().is_none() {
288        let name = match (os.name.value(), os.version.value()) {
289            (Some(name), Some(version)) => Some(format!("{name} {version}")),
290            (Some(name), _) => Some(name.to_owned()),
291            _ => None,
292        };
293
294        if let Some(name) = name {
295            os.os = Annotated::new(name);
296        }
297    }
298}
299
300fn normalize_browser_context(browser: &mut BrowserContext) {
301    // Calculation of the computed context for the browser.
302    // The equivalent calculation is done in `sentry` in `src/sentry/interfaces/contexts.py`.
303    if browser.browser.value().is_none() {
304        let name = match (browser.name.value(), browser.version.value()) {
305            (Some(name), Some(version)) => Some(format!("{name} {version}")),
306            (Some(name), _) => Some(name.to_owned()),
307            _ => None,
308        };
309
310        if let Some(name) = name {
311            browser.browser = Annotated::new(name);
312        }
313    }
314}
315
316fn parse_raw_response_data(response: &ResponseContext) -> Option<(&'static str, Value)> {
317    let raw = response.data.as_str()?;
318
319    serde_json::from_str(raw)
320        .ok()
321        .map(|value| ("application/json", value))
322}
323
324fn normalize_response_data(response: &mut ResponseContext) {
325    // Always derive the `inferred_content_type` from the response body, even if there is a
326    // `Content-Type` header present. This value can technically be ingested (due to the schema) but
327    // should always be overwritten in normalization. Only if inference fails, fall back to the
328    // content type header.
329    if let Some((content_type, parsed_data)) = parse_raw_response_data(response) {
330        // Retain meta data on the body (e.g. trimming annotations) but remove anything on the
331        // inferred content type.
332        response.data.set_value(Some(parsed_data));
333        response.inferred_content_type = Annotated::from(content_type.to_owned());
334    } else {
335        response.inferred_content_type = response
336            .headers
337            .value()
338            .and_then(|headers| headers.get_header("Content-Type"))
339            .map(|value| value.split(';').next().unwrap_or(value).to_owned())
340            .into();
341    }
342}
343
344fn normalize_response(response: &mut ResponseContext) {
345    normalize_response_data(response);
346
347    let headers = match response.headers.value_mut() {
348        Some(headers) => headers,
349        None => return,
350    };
351
352    if response.cookies.value().is_some() {
353        headers.remove("Set-Cookie");
354        return;
355    }
356
357    let cookie_header = match headers.get_header("Set-Cookie") {
358        Some(header) => header,
359        None => return,
360    };
361
362    if let Ok(new_cookies) = Cookies::parse(cookie_header) {
363        response.cookies = Annotated::from(new_cookies);
364        headers.remove("Set-Cookie");
365    }
366}
367
368/// Normalizes the given context.
369pub fn normalize_context(context: &mut Context) {
370    match context {
371        Context::Runtime(runtime) => normalize_runtime_context(runtime),
372        Context::Os(os) => normalize_os_context(os),
373        Context::Browser(browser) => normalize_browser_context(browser),
374        Context::Response(response) => normalize_response(response),
375        Context::Device(device) => {
376            if let Some(product_name) = device
377                .as_ref()
378                .model
379                .value()
380                .and_then(|model| ANDROID_MODEL_NAMES.get(model.as_str()))
381            {
382                device.name.set_value(Some(product_name.to_string()))
383            }
384        }
385        _ => {}
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use relay_event_schema::protocol::{Headers, LenientString, PairList};
392    use relay_protocol::SerializableAnnotated;
393    use similar_asserts::assert_eq;
394
395    use super::*;
396
397    macro_rules! assert_json_context {
398        ($ctx:expr, $($tt:tt)*) => {
399            insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::new($ctx)), $($tt)*)
400
401        };
402    }
403
404    #[test]
405    fn test_get_product_name() {
406        assert_eq!(
407            ANDROID_MODEL_NAMES.get("NE2211").unwrap(),
408            &"OnePlus 10 Pro 5G"
409        );
410
411        assert_eq!(
412            ANDROID_MODEL_NAMES.get("MP04").unwrap(),
413            &"A13 Pro Max 5G EEA"
414        );
415
416        assert_eq!(ANDROID_MODEL_NAMES.get("ZT216_7").unwrap(), &"zyrex");
417
418        assert!(ANDROID_MODEL_NAMES.get("foobar").is_none());
419    }
420
421    #[test]
422    fn test_dotnet_framework_48_without_build_id() {
423        let mut runtime = RuntimeContext {
424            raw_description: ".NET Framework 4.8.4250.0".to_owned().into(),
425            ..RuntimeContext::default()
426        };
427
428        normalize_runtime_context(&mut runtime);
429        assert_json_context!(runtime, @r###"
430        {
431          "runtime": ".NET Framework 4.8.4250.0",
432          "name": ".NET Framework",
433          "version": "4.8.4250.0",
434          "raw_description": ".NET Framework 4.8.4250.0"
435        }
436        "###);
437    }
438
439    #[test]
440    fn test_dotnet_framework_472() {
441        let mut runtime = RuntimeContext {
442            raw_description: ".NET Framework 4.7.3056.0".to_owned().into(),
443            build: LenientString("461814".to_owned()).into(),
444            ..RuntimeContext::default()
445        };
446
447        normalize_runtime_context(&mut runtime);
448        assert_json_context!(runtime, @r###"
449        {
450          "runtime": ".NET Framework 4.7.2",
451          "name": ".NET Framework",
452          "version": "4.7.2",
453          "build": "461814",
454          "raw_description": ".NET Framework 4.7.3056.0"
455        }
456        "###);
457    }
458
459    #[test]
460    fn test_dotnet_framework_future_version() {
461        let mut runtime = RuntimeContext {
462            raw_description: ".NET Framework 200.0".to_owned().into(),
463            build: LenientString("999999".to_owned()).into(),
464            ..RuntimeContext::default()
465        };
466
467        // Unmapped build number doesn't override version
468        normalize_runtime_context(&mut runtime);
469        assert_json_context!(runtime, @r###"
470        {
471          "runtime": ".NET Framework 200.0",
472          "name": ".NET Framework",
473          "version": "200.0",
474          "build": "999999",
475          "raw_description": ".NET Framework 200.0"
476        }
477        "###);
478    }
479
480    #[test]
481    fn test_dotnet_native() {
482        let mut runtime = RuntimeContext {
483            raw_description: ".NET Native 2.0".to_owned().into(),
484            ..RuntimeContext::default()
485        };
486
487        normalize_runtime_context(&mut runtime);
488        assert_json_context!(runtime, @r###"
489        {
490          "runtime": ".NET Native 2.0",
491          "name": ".NET Native",
492          "version": "2.0",
493          "raw_description": ".NET Native 2.0"
494        }
495        "###);
496    }
497
498    #[test]
499    fn test_dotnet_core() {
500        let mut runtime = RuntimeContext {
501            raw_description: ".NET Core 2.0".to_owned().into(),
502            ..RuntimeContext::default()
503        };
504
505        normalize_runtime_context(&mut runtime);
506        assert_json_context!(runtime, @r###"
507        {
508          "runtime": ".NET Core 2.0",
509          "name": ".NET Core",
510          "version": "2.0",
511          "raw_description": ".NET Core 2.0"
512        }
513        "###);
514    }
515
516    #[test]
517    fn test_windows_7_or_server_2008() {
518        // Environment.OSVersion on Windows 7 (CoreCLR 1.0+, .NET Framework 1.1+, Mono 1+)
519        let mut os = OsContext {
520            raw_description: "Microsoft Windows NT 6.1.7601 Service Pack 1"
521                .to_owned()
522                .into(),
523            ..OsContext::default()
524        };
525
526        normalize_os_context(&mut os);
527        assert_json_context!(os, @r###"
528        {
529          "os": "Windows 7",
530          "name": "Windows",
531          "version": "7",
532          "build": "7601",
533          "raw_description": "Microsoft Windows NT 6.1.7601 Service Pack 1"
534        }
535        "###);
536    }
537
538    #[test]
539    fn test_windows_8_or_server_2012_or_later() {
540        // Environment.OSVersion on Windows 10 (CoreCLR 1.0+, .NET Framework 1.1+, Mono 1+)
541        // *or later, due to GetVersionEx deprecated on Windows 8.1
542        // It's a potentially really misleading API on newer platforms
543        // Only used if RuntimeInformation.OSDescription is not available (old runtimes)
544        let mut os = OsContext {
545            raw_description: "Microsoft Windows NT 6.2.9200.0".to_owned().into(),
546            ..OsContext::default()
547        };
548
549        normalize_os_context(&mut os);
550        assert_json_context!(os, @r###"
551        {
552          "os": "Windows 8",
553          "name": "Windows",
554          "version": "8",
555          "build": "9200",
556          "raw_description": "Microsoft Windows NT 6.2.9200.0"
557        }
558        "###);
559    }
560
561    #[test]
562    fn test_windows_10() {
563        // RuntimeInformation.OSDescription on Windows 10 (CoreCLR 2.0+, .NET
564        // Framework 4.7.1+, Mono 5.4+)
565        let mut os = OsContext {
566            raw_description: "Microsoft Windows 10.0.16299".to_owned().into(),
567            ..OsContext::default()
568        };
569
570        normalize_os_context(&mut os);
571        assert_json_context!(os, @r###"
572        {
573          "os": "Windows 10",
574          "name": "Windows",
575          "version": "10",
576          "build": "16299",
577          "raw_description": "Microsoft Windows 10.0.16299"
578        }
579        "###);
580    }
581
582    #[test]
583    fn test_windows_11() {
584        // https://github.com/getsentry/relay/issues/1201
585        let mut os = OsContext {
586            raw_description: "Microsoft Windows 10.0.22000".to_owned().into(),
587            ..OsContext::default()
588        };
589
590        normalize_os_context(&mut os);
591        assert_json_context!(os, @r###"
592        {
593          "os": "Windows 11",
594          "name": "Windows",
595          "version": "11",
596          "build": "22000",
597          "raw_description": "Microsoft Windows 10.0.22000"
598        }
599        "###);
600    }
601
602    #[test]
603    fn test_windows_11_future1() {
604        // This is fictional as of today, but let's be explicit about the behavior we expect.
605        let mut os = OsContext {
606            raw_description: "Microsoft Windows 10.0.22001".to_owned().into(),
607            ..OsContext::default()
608        };
609
610        normalize_os_context(&mut os);
611        assert_json_context!(os, @r###"
612        {
613          "os": "Windows 11",
614          "name": "Windows",
615          "version": "11",
616          "build": "22001",
617          "raw_description": "Microsoft Windows 10.0.22001"
618        }
619        "###);
620    }
621
622    #[test]
623    fn test_windows_11_future2() {
624        // This is fictional, but let's be explicit about the behavior we expect.
625        let mut os = OsContext {
626            raw_description: "Microsoft Windows 10.1.23456".to_owned().into(),
627            ..OsContext::default()
628        };
629
630        normalize_os_context(&mut os);
631        assert_json_context!(os, @r###"
632        {
633          "os": "Windows 10.1.23456",
634          "name": "Windows",
635          "version": "10.1.23456",
636          "build": "23456",
637          "raw_description": "Microsoft Windows 10.1.23456"
638        }
639        "###);
640    }
641
642    #[test]
643    fn test_macos_os_version() {
644        // Environment.OSVersion on macOS (CoreCLR 1.0+, Mono 1+)
645        let mut os = OsContext {
646            raw_description: "Unix 17.5.0.0".to_owned().into(),
647            ..OsContext::default()
648        };
649
650        normalize_os_context(&mut os);
651        assert_json_context!(os, @r###"
652        {
653          "os": "Unix",
654          "name": "Unix",
655          "kernel_version": "17.5.0",
656          "raw_description": "Unix 17.5.0.0"
657        }
658        "###);
659    }
660
661    #[test]
662    fn test_macos_runtime() {
663        // RuntimeInformation.OSDescription on macOS (CoreCLR 2.0+, Mono 5.4+)
664        let mut os = OsContext {
665        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(),
666        ..OsContext::default()
667    };
668
669        normalize_os_context(&mut os);
670        assert_json_context!(os, @r###"
671        {
672          "os": "Darwin",
673          "name": "Darwin",
674          "kernel_version": "17.5.0",
675          "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"
676        }
677        "###);
678    }
679
680    #[test]
681    fn test_centos_os_version() {
682        // Environment.OSVersion on CentOS 7 (CoreCLR 1.0+, Mono 1+)
683        let mut os = OsContext {
684            raw_description: "Unix 3.10.0.693".to_owned().into(),
685            ..OsContext::default()
686        };
687
688        normalize_os_context(&mut os);
689        assert_json_context!(os, @r###"
690        {
691          "os": "Unix",
692          "name": "Unix",
693          "kernel_version": "3.10.0.693",
694          "raw_description": "Unix 3.10.0.693"
695        }
696        "###);
697    }
698
699    #[test]
700    fn test_centos_runtime_info() {
701        // RuntimeInformation.OSDescription on CentOS 7 (CoreCLR 2.0+, Mono 5.4+)
702        let mut os = OsContext {
703            raw_description: "Linux 3.10.0-693.21.1.el7.x86_64 #1 SMP Wed Mar 7 19:03:37 UTC 2018"
704                .to_owned()
705                .into(),
706            ..OsContext::default()
707        };
708
709        normalize_os_context(&mut os);
710        assert_json_context!(os, @r###"
711        {
712          "os": "Linux",
713          "name": "Linux",
714          "kernel_version": "3.10.0",
715          "raw_description": "Linux 3.10.0-693.21.1.el7.x86_64 #1 SMP Wed Mar 7 19:03:37 UTC 2018"
716        }
717        "###);
718    }
719
720    #[test]
721    fn test_wsl_ubuntu() {
722        // RuntimeInformation.OSDescription on Windows Subsystem for Linux (Ubuntu)
723        // (CoreCLR 2.0+, Mono 5.4+)
724        let mut os = OsContext {
725            raw_description: "Linux 4.4.0-43-Microsoft #1-Microsoft Wed Dec 31 14:42:53 PST 2014"
726                .to_owned()
727                .into(),
728            ..OsContext::default()
729        };
730
731        normalize_os_context(&mut os);
732        assert_json_context!(os, @r###"
733        {
734          "os": "Linux",
735          "name": "Linux",
736          "kernel_version": "4.4.0",
737          "raw_description": "Linux 4.4.0-43-Microsoft #1-Microsoft Wed Dec 31 14:42:53 PST 2014"
738        }
739        "###);
740    }
741
742    #[test]
743    fn test_macos_with_build() {
744        let mut os = OsContext {
745            raw_description: "Mac OS X 10.14.2 (18C54)".to_owned().into(),
746            ..OsContext::default()
747        };
748
749        normalize_os_context(&mut os);
750        assert_json_context!(os, @r###"
751        {
752          "os": "macOS 10.14.2",
753          "name": "macOS",
754          "version": "10.14.2",
755          "build": "18C54",
756          "raw_description": "Mac OS X 10.14.2 (18C54)"
757        }
758        "###);
759    }
760
761    #[test]
762    fn test_macos_without_build() {
763        let mut os = OsContext {
764            raw_description: "Mac OS X 10.14.2".to_owned().into(),
765            ..OsContext::default()
766        };
767
768        normalize_os_context(&mut os);
769        assert_json_context!(os, @r###"
770        {
771          "os": "macOS 10.14.2",
772          "name": "macOS",
773          "version": "10.14.2",
774          "raw_description": "Mac OS X 10.14.2"
775        }
776        "###);
777    }
778
779    #[test]
780    fn test_name_not_overwritten() {
781        let mut os = OsContext {
782            name: "Properly defined name".to_owned().into(),
783            raw_description: "Linux 4.4.0".to_owned().into(),
784            ..OsContext::default()
785        };
786
787        normalize_os_context(&mut os);
788        assert_json_context!(os, @r###"
789        {
790          "os": "Properly defined name",
791          "name": "Properly defined name",
792          "raw_description": "Linux 4.4.0"
793        }
794        "###);
795    }
796
797    #[test]
798    fn test_version_not_overwritten() {
799        let mut os = OsContext {
800            version: "Properly defined version".to_owned().into(),
801            raw_description: "Linux 4.4.0".to_owned().into(),
802            ..OsContext::default()
803        };
804
805        normalize_os_context(&mut os);
806        assert_json_context!(os, @r###"
807        {
808          "version": "Properly defined version",
809          "raw_description": "Linux 4.4.0"
810        }
811        "###);
812    }
813
814    #[test]
815    fn test_no_name() {
816        let mut os = OsContext::default();
817
818        normalize_os_context(&mut os);
819        assert_json_context!(os, @"{}");
820    }
821
822    #[test]
823    fn test_unity_mac_os() {
824        let mut os = OsContext {
825            raw_description: "Mac OS X 10.16.0".to_owned().into(),
826            ..OsContext::default()
827        };
828        normalize_os_context(&mut os);
829        assert_json_context!(os, @r###"
830        {
831          "os": "macOS 10.16.0",
832          "name": "macOS",
833          "version": "10.16.0",
834          "raw_description": "Mac OS X 10.16.0"
835        }
836        "###);
837    }
838
839    #[test]
840    fn test_unity_ios() {
841        let mut os = OsContext {
842            raw_description: "iOS 17.5.1".to_owned().into(),
843            ..OsContext::default()
844        };
845
846        normalize_os_context(&mut os);
847        assert_json_context!(os, @r###"
848        {
849          "os": "iOS 17.5.1",
850          "name": "iOS",
851          "version": "17.5.1",
852          "raw_description": "iOS 17.5.1"
853        }
854        "###);
855    }
856
857    #[test]
858    fn test_unity_ipados() {
859        let mut os = OsContext {
860            raw_description: "iPadOS 17.5.1".to_owned().into(),
861            ..OsContext::default()
862        };
863
864        normalize_os_context(&mut os);
865        assert_json_context!(os, @r###"
866        {
867          "os": "iPadOS 17.5.1",
868          "name": "iPadOS",
869          "version": "17.5.1",
870          "raw_description": "iPadOS 17.5.1"
871        }
872        "###);
873    }
874
875    //OS_WINDOWS_REGEX = r#"^(Microsoft )?Windows (NT )?(?P<version>\d+\.\d+\.\d+).*$"#;
876    #[test]
877    fn test_unity_windows_os() {
878        let mut os = OsContext {
879            raw_description: "Windows 10  (10.0.19042) 64bit".to_owned().into(),
880            ..OsContext::default()
881        };
882
883        normalize_os_context(&mut os);
884        assert_json_context!(os, @r###"
885        {
886          "os": "Windows 10",
887          "name": "Windows",
888          "version": "10",
889          "build": "19042",
890          "raw_description": "Windows 10  (10.0.19042) 64bit"
891        }
892        "###);
893    }
894
895    #[test]
896    fn test_unity_android_os() {
897        let mut os = OsContext {
898            raw_description: "Android OS 11 / API-30 (RP1A.201005.001/2107031736)"
899                .to_owned()
900                .into(),
901            ..OsContext::default()
902        };
903
904        normalize_os_context(&mut os);
905        assert_json_context!(os, @r###"
906        {
907          "os": "Android 11",
908          "name": "Android",
909          "version": "11",
910          "raw_description": "Android OS 11 / API-30 (RP1A.201005.001/2107031736)"
911        }
912        "###);
913    }
914
915    #[test]
916    fn test_unity_android_api_version() {
917        let description = "Android OS 11 / API-30 (RP1A.201005.001/2107031736)";
918        assert_eq!(Some("30"), get_android_api_version(description));
919    }
920
921    #[test]
922    fn test_unreal_windows_os() {
923        let mut os = OsContext {
924            raw_description: "Windows 10".to_owned().into(),
925            ..OsContext::default()
926        };
927
928        normalize_os_context(&mut os);
929        assert_json_context!(os, @r###"
930        {
931          "os": "Windows 10",
932          "name": "Windows",
933          "version": "10",
934          "raw_description": "Windows 10"
935        }
936        "###);
937    }
938
939    #[test]
940    fn test_linux_5_11() {
941        let mut os = OsContext {
942            raw_description: "Linux 5.11 Ubuntu 20.04 64bit".to_owned().into(),
943            ..OsContext::default()
944        };
945
946        normalize_os_context(&mut os);
947        assert_json_context!(os, @r###"
948        {
949          "os": "Ubuntu 20.04",
950          "name": "Ubuntu",
951          "version": "20.04",
952          "kernel_version": "5.11",
953          "raw_description": "Linux 5.11 Ubuntu 20.04 64bit"
954        }
955        "###);
956    }
957
958    #[test]
959    fn test_unity_nintendo_switch() {
960        // Format sent by Unity on Nintendo Switch
961        let mut os = OsContext {
962            raw_description: "Nintendo Switch".to_owned().into(),
963            ..OsContext::default()
964        };
965
966        normalize_os_context(&mut os);
967        assert_json_context!(os, @r###"
968        {
969          "os": "Nintendo OS",
970          "name": "Nintendo OS",
971          "raw_description": "Nintendo Switch"
972        }
973        "###);
974    }
975
976    #[test]
977    fn test_android_4_4_2() {
978        let mut os = OsContext {
979            raw_description: "Android OS 4.4.2 / API-19 (KOT49H/A536_S186_150813_ROW)"
980                .to_owned()
981                .into(),
982            ..OsContext::default()
983        };
984
985        normalize_os_context(&mut os);
986        assert_json_context!(os, @r###"
987        {
988          "os": "Android 4.4.2",
989          "name": "Android",
990          "version": "4.4.2",
991          "raw_description": "Android OS 4.4.2 / API-19 (KOT49H/A536_S186_150813_ROW)"
992        }
993        "###);
994    }
995
996    #[test]
997    fn test_infer_json() {
998        let mut response = ResponseContext {
999            data: Annotated::from(Value::String(r#"{"foo":"bar"}"#.to_owned())),
1000            ..ResponseContext::default()
1001        };
1002
1003        normalize_response(&mut response);
1004        assert_json_context!(response, @r###"
1005        {
1006          "data": {
1007            "foo": "bar"
1008          },
1009          "inferred_content_type": "application/json"
1010        }
1011        "###);
1012    }
1013
1014    #[test]
1015    fn test_broken_json_with_fallback() {
1016        let mut response = ResponseContext {
1017            data: Annotated::from(Value::String(r#"{"foo":"b"#.to_owned())),
1018            headers: Annotated::from(Headers(PairList(vec![Annotated::new((
1019                Annotated::new("Content-Type".to_owned().into()),
1020                Annotated::new("text/plain; encoding=utf-8".to_owned().into()),
1021            ))]))),
1022            ..ResponseContext::default()
1023        };
1024
1025        normalize_response(&mut response);
1026        assert_json_context!(response, @r###"
1027        {
1028          "headers": [
1029            [
1030              "Content-Type",
1031              "text/plain; encoding=utf-8"
1032            ]
1033          ],
1034          "data": "{\"foo\":\"b",
1035          "inferred_content_type": "text/plain"
1036        }
1037        "###);
1038    }
1039
1040    #[test]
1041    fn test_broken_json_without_fallback() {
1042        let mut response = ResponseContext {
1043            data: Annotated::from(Value::String(r#"{"foo":"b"#.to_owned())),
1044            ..ResponseContext::default()
1045        };
1046
1047        normalize_response(&mut response);
1048        assert_json_context!(response, @r###"
1049        {
1050          "data": "{\"foo\":\"b"
1051        }
1052        "###);
1053    }
1054
1055    #[test]
1056    fn test_os_computed_context() {
1057        let mut os = OsContext {
1058            name: "Windows".to_owned().into(),
1059            version: "10".to_owned().into(),
1060            ..OsContext::default()
1061        };
1062
1063        normalize_os_context(&mut os);
1064        assert_json_context!(os, @r###"
1065        {
1066          "os": "Windows 10",
1067          "name": "Windows",
1068          "version": "10"
1069        }
1070        "###);
1071    }
1072
1073    #[test]
1074    fn test_os_computed_context_missing_version() {
1075        let mut os = OsContext {
1076            name: "Windows".to_owned().into(),
1077            ..OsContext::default()
1078        };
1079
1080        normalize_os_context(&mut os);
1081        assert_json_context!(os, @r###"
1082        {
1083          "os": "Windows",
1084          "name": "Windows"
1085        }
1086        "###);
1087    }
1088
1089    #[test]
1090    fn test_runtime_computed_context() {
1091        let mut runtime = RuntimeContext {
1092            name: "Python".to_owned().into(),
1093            version: "3.9.0".to_owned().into(),
1094            ..RuntimeContext::default()
1095        };
1096
1097        normalize_runtime_context(&mut runtime);
1098        assert_json_context!(runtime, @r###"
1099        {
1100          "runtime": "Python 3.9.0",
1101          "name": "Python",
1102          "version": "3.9.0"
1103        }
1104        "###);
1105    }
1106
1107    #[test]
1108    fn test_runtime_computed_context_missing_version() {
1109        let mut runtime = RuntimeContext {
1110            name: "Python".to_owned().into(),
1111            ..RuntimeContext::default()
1112        };
1113
1114        normalize_runtime_context(&mut runtime);
1115        assert_json_context!(runtime, @r###"
1116        {
1117          "name": "Python"
1118        }
1119        "###);
1120    }
1121
1122    #[test]
1123    fn test_browser_computed_context() {
1124        let mut browser = BrowserContext {
1125            name: "Firefox".to_owned().into(),
1126            version: "89.0".to_owned().into(),
1127            ..BrowserContext::default()
1128        };
1129
1130        normalize_browser_context(&mut browser);
1131        assert_json_context!(browser, @r###"
1132        {
1133          "browser": "Firefox 89.0",
1134          "name": "Firefox",
1135          "version": "89.0"
1136        }
1137        "###);
1138    }
1139
1140    #[test]
1141    fn test_browser_computed_context_missing_version() {
1142        let mut browser = BrowserContext {
1143            name: "Firefox".to_owned().into(),
1144            ..BrowserContext::default()
1145        };
1146
1147        normalize_browser_context(&mut browser);
1148        assert_json_context!(browser, @r###"
1149        {
1150          "browser": "Firefox",
1151          "name": "Firefox"
1152        }
1153        "###);
1154    }
1155}