Skip to main content

user_agent_parser/
lib.rs

1/*!
2# User Agent Parser
3
4A parser to get the product, OS, device, cpu, and engine information from a user agent, inspired by <https://github.com/faisalman/ua-parser-js> and <https://github.com/ua-parser/uap-core>
5
6## Usage
7
8You can make a **regexes.yaml** file or copy one from <https://github.com/ua-parser/uap-core>
9
10This is a simple example of **regexes.yaml**.
11
12```yaml
13user_agent_parsers:
14  - regex: '(ESPN)[%20| ]+Radio/(\d+)\.(\d+)\.(\d+) CFNetwork'
15  - regex: '(Namoroka|Shiretoko|Minefield)/(\d+)\.(\d+)\.(\d+(?:pre|))'
16    family_replacement: 'Firefox ($1)'
17  - regex: '(Android) Eclair'
18    v1_replacement: '2'
19    v2_replacement: '1'
20
21os_parsers:
22  - regex: 'Win(?:dows)? ?(95|98|3.1|NT|ME|2000|XP|Vista|7|CE)'
23    os_replacement: 'Windows'
24    os_v1_replacement: '$1'
25
26device_parsers:
27  - regex: '\bSmartWatch *\( *([^;]+) *; *([^;]+) *;'
28    device_replacement: '$1 $2'
29    brand_replacement: '$1'
30    model_replacement: '$2'
31```
32
33The CPU and the layout engine are not a part of the uap-core format, so this crate ships its own patterns for them. The optional `cpu_parsers` and `engine_parsers` sections replace those built-in patterns.
34
35```yaml
36cpu_parsers:
37  - regex: 'sun4\w[;)]'
38    architecture_replacement: 'sparc'
39  - regex: '\b(riscv\d*)\b'
40
41engine_parsers:
42  - regex: '(myengine)/(\w+)(?:\.(\w+))?(?:\.(\w+))?'
43    engine_replacement: 'MyEngine'
44```
45
46In `cpu_parsers`, the first capture group is the architecture. In `engine_parsers`, the first one is the name, and the second, third and fourth ones are the major, minor and patch versions. A `*_replacement` overrides the group it stands for, just like in the sections above. Leaving a section out keeps every built-in pattern, and writing one down replaces all of them.
47
48Then, use the `from_path` (or `from_str` if your YAML data is in-memory) associated function to create a `UserAgentParser` instance.
49
50
51```rust,no_run
52use user_agent_parser::UserAgentParser;
53
54let ua_parser = UserAgentParser::from_path("/path/to/regexes.yaml").unwrap();
55```
56
57Use the `parse_*` methods and input a user-agent string to get information.
58
59```rust,no_run
60use user_agent_parser::UserAgentParser;
61
62let ua_parser = UserAgentParser::from_path("/path/to/regexes.yaml").unwrap();
63
64let user_agent = "Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0 [FBAN/FBIOS;FBAV/8.0.0.28.18;FBBV/1665515;FBDV/iPhone4,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/7.0.4;FBSS/2; FBCR/Telekom.de;FBID/phone;FBLC/de_DE;FBOP/5]";
65
66let product = ua_parser.parse_product(user_agent);
67
68println!("{:#?}", product);
69
70//    Product {
71//        name: Some(
72//            "Facebook",
73//        ),
74//        major: Some(
75//            "8",
76//        ),
77//        minor: Some(
78//            "0",
79//        ),
80//        patch: Some(
81//            "0",
82//        ),
83//        patch_minor: None,
84//    }
85
86let os = ua_parser.parse_os(user_agent);
87
88println!("{:#?}", os);
89
90//    OS {
91//        name: Some(
92//            "iOS",
93//        ),
94//        major: None,
95//        minor: None,
96//        patch: None,
97//        patch_minor: None,
98//    }
99
100let device = ua_parser.parse_device(user_agent);
101
102println!("{:#?}", device);
103
104//    Device {
105//        name: Some(
106//            "iPhone",
107//        ),
108//        brand: Some(
109//            "Apple",
110//        ),
111//        model: Some(
112//            "iPhone4,1",
113//        ),
114//    }
115
116let cpu = ua_parser.parse_cpu(user_agent);
117
118println!("{:#?}", cpu);
119
120//    CPU {
121//        architecture: Some(
122//            "amd64",
123//        ),
124//    }
125
126let engine = ua_parser.parse_engine(user_agent);
127
128println!("{:#?}", engine);
129
130//    Engine {
131//        name: Some(
132//            "Gecko",
133//        ),
134//        major: Some(
135//            "10",
136//        ),
137//        minor: Some(
138//            "0",
139//        ),
140//        patch: None,
141//    }
142```
143
144The `Product`, `OS` and `Engine` models can also join their version parts into one string with the `version` method. It starts at the major version and stops at the first part which is missing.
145
146```rust,no_run
147use user_agent_parser::UserAgentParser;
148
149let ua_parser = UserAgentParser::from_path("/path/to/regexes.yaml").unwrap();
150
151let product = ua_parser.parse_product("Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36");
152
153assert_eq!(Some("79.0.3945.79"), product.version().as_deref());
154```
155
156The lifetime of result instances of the `parse_*` methods depends on the user-agent string and the `UserAgentParser` instance. To make it independent, call the `into_owned` method.
157
158```rust,no_run
159use user_agent_parser::UserAgentParser;
160
161let ua_parser = UserAgentParser::from_path("/path/to/regexes.yaml").unwrap();
162
163let product = ua_parser.parse_product("Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9.2.12) Gecko/20101027 Ubuntu/10.04 (lucid) Firefox/3.6.12").into_owned();
164```
165
166## Rocket Support
167
168This crate supports the Rocket framework. All you have to do is enabling the `rocket` feature for this crate.
169
170```toml
171[dependencies.user-agent-parser]
172version = "*"
173features = ["rocket"]
174```
175
176Let `Rocket` manage a `UserAgentParser` instance, and the `Product`, `OS`, `Device`, `CPU`, `Engine` models of this crate (plus the `UserAgent` model) can be used as *Request Guards*.
177
178The `Product`, `OS`, `Device`, `CPU` and `Engine` guards panic if no `UserAgentParser` is managed, because that is a mistake in the setup rather than something a single request can recover from. The `UserAgent` guard needs no parser at all. A request without a `User-Agent` header is fine and yields the default model.
179
180```rust,ignore
181#[macro_use]
182extern crate rocket;
183
184use user_agent_parser::{UserAgentParser, UserAgent, Product, OS, Device, CPU, Engine};
185
186#[get("/")]
187fn index(user_agent: UserAgent, product: Product, os: OS, device: Device, cpu: CPU, engine: Engine) -> String {
188    format!("{user_agent:#?}\n{product:#?}\n{os:#?}\n{device:#?}\n{cpu:#?}\n{engine:#?}",
189            user_agent = user_agent,
190            product = product,
191            os = os,
192            device = device,
193            cpu = cpu,
194            engine = engine,
195    )
196}
197
198#[launch]
199fn rocket() -> _ {
200    rocket::build()
201        .manage(UserAgentParser::from_path("/path/to/regexes.yaml").unwrap())
202        .mount("/", routes![index])
203}
204```
205
206## Axum Support
207
208This crate also supports the Axum framework. All you have to do is enabling the `axum` feature for this crate.
209
210```toml
211[dependencies.user-agent-parser]
212version = "*"
213features = ["axum"]
214```
215
216Share a `UserAgentParser` instance through an `Extension<Arc<UserAgentParser>>` layer, and the owned models `Product<'static>`, `OS<'static>`, `Device<'static>`, `CPU<'static>`, `Engine<'static>` (plus the `UserAgent<'static>` model) can be used as *extractors*.
217
218The `Product`, `OS`, `Device`, `CPU` and `Engine` extractors panic if that layer is missing, because that is a mistake in the setup rather than something a single request can recover from. The `UserAgent` extractor needs no parser at all. A request without a `User-Agent` header is fine and yields the default model.
219
220```rust,ignore
221use std::sync::Arc;
222
223use axum::{routing::get, Extension, Router};
224use user_agent_parser::{UserAgentParser, UserAgent, Product, OS, Device, CPU, Engine};
225
226async fn index(
227    user_agent: UserAgent<'static>,
228    product: Product<'static>,
229    os: OS<'static>,
230    device: Device<'static>,
231    cpu: CPU<'static>,
232    engine: Engine<'static>,
233) -> String {
234    format!("{user_agent:#?}\n{product:#?}\n{os:#?}\n{device:#?}\n{cpu:#?}\n{engine:#?}")
235}
236
237#[tokio::main]
238async fn main() {
239    let app = Router::new()
240        .route("/", get(index))
241        .layer(Extension(Arc::new(UserAgentParser::from_path("/path/to/regexes.yaml").unwrap())));
242
243    let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
244
245    axum::serve(listener, app).await.unwrap();
246}
247```
248
249## Testing
250
251```bash
252# git clone --recurse-submodules https://github.com/magiclen/user-agent-parser.git
253
254git clone https://github.com/magiclen/user-agent-parser.git
255
256cd user-agent-parser
257
258git submodule init
259git submodule update --recursive
260
261cargo test
262```
263*/
264
265#![cfg_attr(docsrs, feature(doc_cfg))]
266
267mod errors;
268mod models;
269mod regexes;
270
271#[cfg(feature = "rocket")]
272mod request_guards;
273
274#[cfg(feature = "axum")]
275mod request_guards_axum;
276
277use std::{borrow::Cow, fs, path::Path, str::FromStr};
278
279pub use errors::UserAgentParserError;
280pub use models::*;
281use regexes::*;
282use yaml_rust::{Yaml, YamlLoader};
283
284/// A set of regular expressions which can extract the product, OS, device, CPU and engine information out of a user-agent string.
285#[derive(Debug)]
286pub struct UserAgentParser {
287    product_regexes: Vec<ProductRegex>,
288    os_regexes:      Vec<OSRegex>,
289    device_regexes:  Vec<DeviceRegex>,
290    // A borrowed slice is the built-in list, which is what a source without the matching section gets.
291    cpu_regexes:     Cow<'static, [CPURegex]>,
292    engine_regexes:  Cow<'static, [EngineRegex]>,
293}
294
295impl UserAgentParser {
296    /// Read the list of regular expressions (YAML data) from a file to create a `UserAgentParser` instance.
297    #[inline]
298    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<UserAgentParser, UserAgentParserError> {
299        let yaml = fs::read_to_string(path)?;
300
301        Self::from_str(yaml)
302    }
303
304    /// Read the list of regular expressions (YAML data) from a string to create a `UserAgentParser` instance.
305    #[allow(clippy::should_implement_trait)]
306    pub fn from_str<S: AsRef<str>>(yaml: S) -> Result<UserAgentParser, UserAgentParserError> {
307        let yamls = YamlLoader::load_from_str(yaml.as_ref())?;
308
309        // More than one document means the source is not a plain list of regular expressions, and reading only the first one would silently drop the rest.
310        let [yaml] = yamls.as_slice() else {
311            return Err(UserAgentParserError::IncorrectSource);
312        };
313
314        match yaml.as_hash() {
315            Some(yaml) => {
316                let user_agent_parsers = yaml.get(&Yaml::String("user_agent_parsers".to_string()));
317                let os_parsers = yaml.get(&Yaml::String("os_parsers".to_string()));
318                let device_parsers = yaml.get(&Yaml::String("device_parsers".to_string()));
319                let cpu_parsers = yaml.get(&Yaml::String("cpu_parsers".to_string()));
320                let engine_parsers = yaml.get(&Yaml::String("engine_parsers".to_string()));
321
322                let product_regexes = match user_agent_parsers {
323                    Some(user_agent_parsers) => ProductRegex::from_yaml(user_agent_parsers)?,
324                    None => Vec::new(),
325                };
326
327                let os_regexes = match os_parsers {
328                    Some(os_parsers) => OSRegex::from_yaml(os_parsers)?,
329                    None => Vec::new(),
330                };
331
332                let device_regexes = match device_parsers {
333                    Some(device_parsers) => DeviceRegex::from_yaml(device_parsers)?,
334                    None => Vec::new(),
335                };
336
337                // The CPU and the engine sections are optional, and leaving one out keeps the built-in patterns instead of parsing nothing.
338                let cpu_regexes = match cpu_parsers {
339                    Some(cpu_parsers) => Cow::from(CPURegex::from_yaml(cpu_parsers)?),
340                    None => Cow::from(CPURegex::built_in_regexes()),
341                };
342
343                let engine_regexes = match engine_parsers {
344                    Some(engine_parsers) => Cow::from(EngineRegex::from_yaml(engine_parsers)?),
345                    None => Cow::from(EngineRegex::built_in_regexes()),
346                };
347
348                Ok(UserAgentParser {
349                    product_regexes,
350                    os_regexes,
351                    device_regexes,
352                    cpu_regexes,
353                    engine_regexes,
354                })
355            },
356            None => Err(UserAgentParserError::IncorrectSource),
357        }
358    }
359}
360
361/// The results borrow from both the parser and the user-agent string, so call `into_owned` on them to break that tie.
362impl UserAgentParser {
363    /// Extracts the product information, falling back to a `name` of `Some("Other")` when no pattern matches.
364    pub fn parse_product<'a, S: AsRef<str> + ?Sized>(&'a self, user_agent: &'a S) -> Product<'a> {
365        let user_agent = user_agent.as_ref();
366
367        let mut product = Product::default();
368
369        // Do not replace this walk with a `RegexSet`, it was tried and it is much slower.
370        // A `RegexSet` has to report every pattern that matches, so it cannot stop at the first one and cannot use the fast leftmost engine.
371        // Trying the patterns one by one wins because each one carries its own literal prefilter, which rejects most of them almost for free.
372        // Measured against uap-core (433 product, 204 OS and 633 device patterns) with `cargo bench`:
373        // parse_product 94us -> 4852us, parse_os 20us -> 970us, parse_device 226us -> 4769us, and from_path 234ms -> 528ms.
374        for product_regex in self.product_regexes.iter() {
375            // `is_match` skips capture tracking, so the regexes that do not match cost much less than a `captures` call.
376            if !product_regex.regex.is_match(user_agent) {
377                continue;
378            }
379
380            let captures = product_regex.regex.captures(user_agent).unwrap();
381
382            product.name = resolve(1, product_regex.family_replacement.as_ref(), &captures);
383            product.major = resolve(2, product_regex.v1_replacement.as_ref(), &captures);
384            product.minor = resolve(3, product_regex.v2_replacement.as_ref(), &captures);
385            product.patch = resolve(4, product_regex.v3_replacement.as_ref(), &captures);
386            // uap-core neither documents this fifth part nor gives it a replacement of its own, but its test data does expect it.
387            product.patch_minor = capture_str(5, &captures).map(Cow::from);
388
389            break;
390        }
391
392        if product.name.is_none() {
393            product.name = Some(Cow::from("Other"));
394        }
395
396        product
397    }
398
399    /// Extracts the OS information, falling back to a `name` of `Some("Other")` when no pattern matches.
400    pub fn parse_os<'a, S: AsRef<str> + ?Sized>(&'a self, user_agent: &'a S) -> OS<'a> {
401        let user_agent = user_agent.as_ref();
402
403        let mut os = OS::default();
404
405        for os_regex in self.os_regexes.iter() {
406            if !os_regex.regex.is_match(user_agent) {
407                continue;
408            }
409
410            let captures = os_regex.regex.captures(user_agent).unwrap();
411
412            os.name = resolve(1, os_regex.os_replacement.as_ref(), &captures);
413            os.major = resolve(2, os_regex.os_v1_replacement.as_ref(), &captures);
414            os.minor = resolve(3, os_regex.os_v2_replacement.as_ref(), &captures);
415            os.patch = resolve(4, os_regex.os_v3_replacement.as_ref(), &captures);
416            os.patch_minor = resolve(5, os_regex.os_v4_replacement.as_ref(), &captures);
417
418            break;
419        }
420
421        if os.name.is_none() {
422            os.name = Some(Cow::from("Other"));
423        }
424
425        os
426    }
427
428    /// Extracts the device information, falling back to a `name` of `Some("Other")` when no pattern matches.
429    pub fn parse_device<'a, S: AsRef<str> + ?Sized>(&'a self, user_agent: &'a S) -> Device<'a> {
430        let user_agent = user_agent.as_ref();
431
432        let mut device = Device::default();
433
434        for device_regex in self.device_regexes.iter() {
435            if !device_regex.regex.is_match(user_agent) {
436                continue;
437            }
438
439            let captures = device_regex.regex.captures(user_agent).unwrap();
440
441            device.name = resolve(1, device_regex.device_replacement.as_ref(), &captures);
442            // uap-core gives the brand no positional default; it is only set when a `brand_replacement` exists.
443            device.brand =
444                device_regex.brand_replacement.as_ref().and_then(|r| r.resolve(&captures));
445            device.model = resolve(1, device_regex.model_replacement.as_ref(), &captures);
446
447            break;
448        }
449
450        if device.name.is_none() {
451            device.name = Some(Cow::from("Other"));
452        }
453
454        device
455    }
456
457    /// Extracts the CPU architecture, which stays `None` when no pattern matches.
458    pub fn parse_cpu<'a, S: AsRef<str> + ?Sized>(&'a self, user_agent: &'a S) -> CPU<'a> {
459        let user_agent = user_agent.as_ref();
460
461        let mut cpu = CPU::default();
462
463        for cpu_regex in self.cpu_regexes.iter() {
464            if !cpu_regex.regex.is_match(user_agent) {
465                continue;
466            }
467
468            cpu.architecture = match &cpu_regex.architecture_replacement {
469                // Every built-in pattern names its architecture with a literal, which is resolved without ever touching the captures.
470                Some(Replacement::Literal(architecture)) if !architecture.is_empty() => {
471                    Some(Cow::from(architecture.as_str()))
472                },
473                replacement => {
474                    let captures = cpu_regex.regex.captures(user_agent).unwrap();
475
476                    resolve(1, replacement.as_ref(), &captures)
477                },
478            };
479
480            break;
481        }
482
483        cpu
484    }
485
486    /// Extracts the layout engine information, which stays `None` when no pattern matches.
487    pub fn parse_engine<'a, S: AsRef<str> + ?Sized>(&'a self, user_agent: &'a S) -> Engine<'a> {
488        let user_agent = user_agent.as_ref();
489
490        let mut engine = Engine::default();
491
492        for engine_regex in self.engine_regexes.iter() {
493            if !engine_regex.regex.is_match(user_agent) {
494                continue;
495            }
496
497            let captures = engine_regex.regex.captures(user_agent).unwrap();
498
499            // Every built-in pattern names its engine with a literal, and the versions always come straight from the captures.
500            engine.name = resolve(1, engine_regex.engine_replacement.as_ref(), &captures);
501            engine.major = capture_str(2, &captures).map(Cow::from);
502            engine.minor = capture_str(3, &captures).map(Cow::from);
503            engine.patch = capture_str(4, &captures).map(Cow::from);
504
505            break;
506        }
507
508        engine
509    }
510}
511
512impl FromStr for UserAgentParser {
513    type Err = UserAgentParserError;
514
515    #[inline]
516    fn from_str(s: &str) -> Result<Self, Self::Err> {
517        UserAgentParser::from_str(s)
518    }
519}