1use std::collections::HashMap;
2use std::sync::{Arc, LazyLock};
3
4use serde::Deserialize;
5
6use crate::profile::{BrowserProfile, HeaderStyle, Platform, PlatformIdentity, TlsProfile};
7use crate::{Error, Kind};
8
9include!(concat!(env!("OUT_DIR"), "/chromium_brand.rs"));
10
11impl std::fmt::Display for ChromiumBrand {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 f.write_str(self.id())
14 }
15}
16
17string_id_serde!(ChromiumBrand);
18
19impl std::str::FromStr for ChromiumBrand {
20 type Err = Error;
21
22 fn from_str(name: &str) -> Result<Self, Self::Err> {
23 BRAND_ALL
24 .iter()
25 .zip(BRAND_KEYS)
26 .find(|(_, key)| key.eq_ignore_ascii_case(name))
27 .map(|(brand, _)| *brand)
28 .ok_or_else(|| {
29 Error::new(Kind::Config).with_message(format!(
30 "unknown Chromium brand {name:?}; expected one of {}",
31 BRAND_KEYS.join(", ")
32 ))
33 })
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub(crate) enum BrandOverlayError {
40 Unverified {
41 brand: ChromiumBrand,
42 chromium_major: u32,
43 platform: Platform,
44 },
45}
46
47impl std::fmt::Display for BrandOverlayError {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 Self::Unverified {
51 brand,
52 chromium_major,
53 platform,
54 } => write!(
55 f,
56 "{} overlay on Chromium {chromium_major} / {platform} is not verified \
57 against a live capture; drop the brand or pick an anchor that has one",
58 brand.label(),
59 ),
60 }
61 }
62}
63
64impl std::error::Error for BrandOverlayError {}
65
66impl ChromiumBrand {
67 pub fn all() -> &'static [ChromiumBrand] {
68 BRAND_ALL
69 }
70
71 #[must_use]
72 pub fn id(&self) -> &'static str {
73 BRAND_KEYS[*self as usize]
74 }
75
76 pub(crate) fn label(self) -> &'static str {
77 BRAND_LABELS[self as usize]
78 }
79
80 pub(crate) fn overlay(
81 self,
82 chromium_major: u32,
83 platform: Platform,
84 profile_user_agent: &str,
85 ) -> Result<Option<BrandOverlay>, BrandOverlayError> {
86 let unverified = || BrandOverlayError::Unverified {
87 brand: self,
88 chromium_major,
89 platform,
90 };
91 let row = brand_row(self).ok_or_else(unverified)?;
92 let Some(token) = row.ua_token.as_deref() else {
93 return Ok(None);
94 };
95 if row.desktop_only && !is_desktop(platform) {
96 return Err(unverified());
97 }
98 let version = row
99 .version(chromium_major, ua_seed(profile_user_agent))
100 .ok_or_else(unverified)?;
101 let product = match row.product.as_deref() {
102 Some(name) => Some((name, version_major(&version).ok_or_else(unverified)?)),
103 None => None,
104 };
105 Ok(Some(BrandOverlay {
106 user_agent: format!("{profile_user_agent} {token}/{version}"),
107 sec_ch_ua: sec_ch_ua(chromium_major, product),
108 }))
109 }
110}
111
112impl ChromiumBrand {
113 pub(crate) fn apply(
114 self,
115 chromium_major: u32,
116 platform: Platform,
117 identity: &mut PlatformIdentity,
118 ) -> Result<(), BrandOverlayError> {
119 if let Some(overlay) = self.overlay(chromium_major, platform, &identity.user_agent)? {
120 identity.user_agent = overlay.user_agent;
121 identity.sec_ch_ua = overlay.sec_ch_ua;
122 }
123 Ok(())
124 }
125
126 pub(crate) fn header_style(self) -> Option<HeaderStyle> {
127 brand_row(self).and_then(|row| row.header_style)
128 }
129
130 pub(crate) fn tls_profile(self, profile: Arc<BrowserProfile>) -> Arc<BrowserProfile> {
131 let quic_tls = profile.h3.as_ref().and_then(|h3| h3.tls.as_ref());
132 let Some(tls) = brand_row(self).map(|row| &row.tls).filter(|tls| {
133 tls.changes(&profile.tls) || quic_tls.is_some_and(|quic| tls.changes(quic))
134 }) else {
135 return profile;
136 };
137 let mut branded = (*profile).clone();
138 tls.apply(&mut branded.tls);
139 if let Some(quic) = branded.h3.as_mut().and_then(|h3| h3.tls.as_mut()) {
140 tls.apply(quic);
141 }
142 Arc::new(branded)
143 }
144}
145
146#[derive(Debug, Clone)]
147#[non_exhaustive]
148pub(crate) struct BrandOverlay {
149 pub(crate) user_agent: String,
150 pub(crate) sec_ch_ua: String,
151}
152
153#[derive(Debug, Deserialize)]
154struct BrandRow {
155 product: Option<String>,
156 ua_token: Option<String>,
157 #[serde(default)]
158 follow_chromium: bool,
159 #[serde(default)]
160 desktop_only: bool,
161 #[serde(default)]
162 versions: HashMap<String, Vec<String>>,
163 header_style: Option<HeaderStyle>,
164 #[serde(default)]
165 tls: BrandTls,
166}
167
168#[derive(Debug, Default, Deserialize)]
169struct BrandTls {
170 request_trust_anchors: Option<bool>,
171}
172
173impl BrandTls {
174 fn changes(&self, tls: &TlsProfile) -> bool {
175 self.request_trust_anchors
176 .is_some_and(|request| request != tls.request_trust_anchors)
177 }
178
179 fn apply(&self, tls: &mut TlsProfile) {
180 if let Some(request) = self.request_trust_anchors {
181 tls.request_trust_anchors = request;
182 }
183 tls.fingerprint = None;
184 }
185}
186
187impl BrandRow {
188 fn version(&self, chromium_major: u32, seed: u64) -> Option<String> {
189 if self.follow_chromium {
190 return Some(format!("{chromium_major}.0.0.0"));
191 }
192 let builds = self.versions.get(&chromium_major.to_string())?;
193 let count = u64::try_from(builds.len()).ok().filter(|n| *n > 0)?;
194 builds.get(usize::try_from(seed % count).ok()?).cloned()
195 }
196}
197
198static BRANDS: LazyLock<HashMap<String, BrandRow>> = LazyLock::new(|| {
199 toml::from_str(include_str!("../../profiles/brands.toml"))
200 .expect("built-in brand table is statically valid")
201});
202
203fn brand_row(brand: ChromiumBrand) -> Option<&'static BrandRow> {
204 BRANDS.get(brand.label())
205}
206
207fn version_major(version: &str) -> Option<u32> {
208 version.split('.').next()?.parse().ok()
209}
210
211fn ua_seed(profile_user_agent: &str) -> u64 {
212 profile_user_agent.bytes().fold(0u64, |acc, b| {
213 acc.wrapping_mul(1099511628211).wrapping_add(u64::from(b))
214 })
215}
216
217fn is_desktop(platform: Platform) -> bool {
218 match platform {
219 Platform::Windows | Platform::MacOS | Platform::Linux => true,
220 Platform::Android | Platform::IOS => false,
221 Platform::Host => is_desktop(Platform::detect_host()),
222 }
223}
224
225const GREASE_CHARS: [char; 11] = [' ', '(', ':', '-', '.', '/', ')', ';', '=', '?', '_'];
226const GREASE_VERSIONS: [&str; 3] = ["8", "99", "24"];
227const BRAND_ORDERS: [[usize; 3]; 6] = [
228 [0, 1, 2],
229 [0, 2, 1],
230 [1, 0, 2],
231 [1, 2, 0],
232 [2, 0, 1],
233 [2, 1, 0],
234];
235
236fn cycle<T: Copy>(table: &[T], seed: u32) -> T {
237 table[usize::try_from(seed).unwrap_or_default() % table.len()]
238}
239
240pub(crate) fn sec_ch_ua(chromium_major: u32, product: Option<(&str, u32)>) -> String {
241 let grease = format!(
242 "Not{}A{}Brand",
243 cycle(&GREASE_CHARS, chromium_major),
244 cycle(&GREASE_CHARS, chromium_major.wrapping_add(1)),
245 );
246 let entries = [
247 Some((grease, cycle(&GREASE_VERSIONS, chromium_major).to_string())),
248 Some(("Chromium".to_string(), chromium_major.to_string())),
249 product.map(|(name, version)| (name.to_string(), version.to_string())),
250 ];
251 let mut slots: [Option<(String, String)>; 3] = Default::default();
252 for (entry, slot) in entries
253 .into_iter()
254 .zip(cycle(&BRAND_ORDERS, chromium_major))
255 {
256 slots[slot] = entry;
257 }
258 slots
259 .iter()
260 .flatten()
261 .map(|(name, version)| format!(r#""{name}";v="{version}""#))
262 .collect::<Vec<_>>()
263 .join(", ")
264}
265
266#[cfg(test)]
267mod tests;