Skip to main content

leyline/profile/
brand.rs

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;