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}