Skip to main content

browser_commander/browser/
restrictions.rs

1//! Named, opt-in launch restrictions (issue #103).
2//!
3//! By default Browser Commander starts Chrome exactly like a person would:
4//! `--user-data-dir=<fresh profile> --remote-debugging-port=<reserved port>`
5//! and nothing else. Every switch the library used to add on its own - and
6//! every switch an automation engine adds - is available here by name, so a
7//! caller who wants one asks for it and the difference from a hand-started
8//! Chrome stays visible in their code:
9//!
10//! ```rust,no_run
11//! use browser_commander::browser::RealBrowserOptions;
12//!
13//! let options = RealBrowserOptions::default().restrictions(["no-extensions", "no-sync"]);
14//! ```
15//!
16//! The catalogue is data, not code. `launch-restrictions.json` next to this
17//! module is a byte-for-byte copy of `js/src/browser/launch-restrictions.json`,
18//! embedded with `include_str!` and kept in step by
19//! `scripts/check-shared-fingerprint-assets.sh`.
20
21use std::collections::{BTreeMap, HashMap};
22use std::sync::LazyLock;
23
24use anyhow::{anyhow, Result};
25use serde::{Deserialize, Serialize};
26
27/// The shared catalogue source, embedded at compile time.
28pub const LAUNCH_RESTRICTIONS_SOURCE: &str = include_str!("launch-restrictions.json");
29
30/// One named launch restriction.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct LaunchRestriction {
34    /// Stable name used in `restrictions`.
35    pub id: String,
36    /// What it changes compared with a hand-started browser.
37    pub description: String,
38    /// Chrome switches.
39    #[serde(default)]
40    pub args: Vec<String>,
41    /// Values merged into one `--disable-features` switch.
42    #[serde(default)]
43    pub disable_features: Vec<String>,
44    /// Environment for the browser process only.
45    #[serde(default)]
46    pub env: BTreeMap<String, String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct Catalogue {
51    restrictions: Vec<LaunchRestriction>,
52    presets: serde_json::Map<String, serde_json::Value>,
53}
54
55/// Named presets in catalogue order.
56type Presets = Vec<(String, Vec<String>)>;
57
58static CATALOGUE: LazyLock<(Vec<LaunchRestriction>, Presets)> = LazyLock::new(|| {
59    let catalogue: Catalogue = serde_json::from_str(LAUNCH_RESTRICTIONS_SOURCE)
60        .expect("launch-restrictions.json is embedded at compile time and has to parse");
61    // serde_json keeps object keys sorted, so the preset order follows the
62    // catalogue's key order only when it is already alphabetical - which
63    // it is; the order is only used in error messages.
64    let presets = catalogue
65        .presets
66        .into_iter()
67        .map(|(name, ids)| {
68            let ids = serde_json::from_value(ids)
69                .expect("launch restriction presets are arrays of restriction ids");
70            (name, ids)
71        })
72        .collect();
73    (catalogue.restrictions, presets)
74});
75
76/// Every launch restriction, in the order the catalogue declares them.
77pub fn launch_restrictions() -> &'static [LaunchRestriction] {
78    &CATALOGUE.0
79}
80
81/// Named groups of restrictions, such as the pre-#103 defaults
82/// (`legacy-defaults`, `legacy-launch-browser`).
83pub fn launch_restriction_presets() -> &'static [(String, Vec<String>)] {
84    &CATALOGUE.1
85}
86
87/// Restriction ids a preset expands to, if `name` is a preset.
88pub fn launch_restriction_preset(name: &str) -> Option<&'static [String]> {
89    launch_restriction_presets()
90        .iter()
91        .find(|(preset, _)| preset == name)
92        .map(|(_, ids)| ids.as_slice())
93}
94
95/// Switches and environment for a set of restrictions.
96#[derive(Debug, Clone, PartialEq, Eq, Default)]
97pub struct ResolvedRestrictions {
98    /// Restriction ids after preset expansion, without duplicates.
99    pub ids: Vec<String>,
100    /// Chrome switches, with every `disableFeatures` value in one
101    /// `--disable-features` switch at the end.
102    pub args: Vec<String>,
103    /// Environment for the browser process only.
104    pub env: HashMap<String, String>,
105}
106
107fn expand<S: AsRef<str>>(names: &[S]) -> Vec<String> {
108    let mut ids: Vec<String> = Vec::new();
109    for name in names {
110        let name = name.as_ref();
111        let expanded = launch_restriction_preset(name)
112            .map(<[String]>::to_vec)
113            .unwrap_or_else(|| vec![name.to_owned()]);
114        for id in expanded {
115            if !ids.contains(&id) {
116                ids.push(id);
117            }
118        }
119    }
120    ids
121}
122
123/// Resolve restriction names (and preset names) into switches and environment.
124///
125/// Unknown names are an error listing every restriction and preset.
126pub fn resolve_restrictions<S: AsRef<str>>(names: &[S]) -> Result<ResolvedRestrictions> {
127    let ids = expand(names);
128    let mut args = Vec::new();
129    let mut disable_features = Vec::new();
130    let mut env = HashMap::new();
131    for id in &ids {
132        let Some(restriction) = launch_restrictions().iter().find(|entry| &entry.id == id) else {
133            let expected: Vec<&str> = launch_restrictions()
134                .iter()
135                .map(|entry| entry.id.as_str())
136                .chain(
137                    launch_restriction_presets()
138                        .iter()
139                        .map(|(name, _)| name.as_str()),
140                )
141                .collect();
142            return Err(anyhow!(
143                "Unknown launch restriction \"{id}\". Expected one of {}",
144                expected.join(", ")
145            ));
146        };
147        args.extend(restriction.args.iter().cloned());
148        disable_features.extend(restriction.disable_features.iter().cloned());
149        env.extend(
150            restriction
151                .env
152                .iter()
153                .map(|(key, value)| (key.clone(), value.clone())),
154        );
155    }
156    if !disable_features.is_empty() {
157        args.push(format!("--disable-features={}", disable_features.join(",")));
158    }
159    Ok(ResolvedRestrictions { ids, args, env })
160}
161
162const LIST_SWITCHES: &[&str] = &[
163    "--disable-features",
164    "--enable-features",
165    "--disable-blink-features",
166    "--enable-blink-features",
167];
168
169fn list_switch_of(argument: &str) -> Option<&'static str> {
170    LIST_SWITCHES.iter().copied().find(|prefix| {
171        argument
172            .strip_prefix(prefix)
173            .is_some_and(|rest| rest.starts_with('='))
174    })
175}
176
177/// Merge repeated feature-list switches into one occurrence each.
178///
179/// Chrome keeps only the last `--disable-features` (and friends), so two
180/// sources that each add one would silently cancel each other. The merged
181/// switch takes the place of the first occurrence.
182pub fn merge_feature_switches<S: AsRef<str>>(args: &[S]) -> Vec<String> {
183    let mut values: HashMap<&'static str, Vec<String>> = HashMap::new();
184    for argument in args {
185        let argument = argument.as_ref();
186        if let Some(name) = list_switch_of(argument) {
187            let list = values.entry(name).or_default();
188            for feature in argument[name.len() + 1..].split(',') {
189                if !feature.is_empty() && !list.iter().any(|existing| existing == feature) {
190                    list.push(feature.to_owned());
191                }
192            }
193        }
194    }
195    let mut emitted = Vec::new();
196    let mut merged = Vec::new();
197    for argument in args {
198        let argument = argument.as_ref();
199        match list_switch_of(argument) {
200            None => merged.push(argument.to_owned()),
201            Some(name) if !emitted.contains(&name) => {
202                emitted.push(name);
203                merged.push(format!("{name}={}", values[name].join(",")));
204            }
205            Some(_) => {}
206        }
207    }
208    merged
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn resolves_restrictions_and_presets() {
217        let resolved = resolve_restrictions(&["no-sync", "no-translate", "no-sync"]).unwrap();
218        assert_eq!(resolved.ids, ["no-sync", "no-translate"]);
219        assert_eq!(
220            resolved.args.last().unwrap(),
221            "--disable-features=Translate"
222        );
223
224        let legacy = resolve_restrictions(&["legacy-launch-browser"]).unwrap();
225        assert_eq!(
226            legacy.env.get("GOOGLE_API_KEY").map(String::as_str),
227            Some("no")
228        );
229        assert_eq!(
230            legacy
231                .env
232                .get("GOOGLE_DEFAULT_CLIENT_ID")
233                .map(String::as_str),
234            Some("no")
235        );
236        assert_eq!(
237            legacy
238                .env
239                .get("GOOGLE_DEFAULT_CLIENT_SECRET")
240                .map(String::as_str),
241            Some("no")
242        );
243        assert!(legacy.args.contains(&"--password-store=basic".to_owned()));
244    }
245
246    #[test]
247    fn legacy_defaults_match_the_chrome_args_constant() {
248        let legacy = resolve_restrictions(&["legacy-defaults"]).unwrap();
249        let mut expected: Vec<String> = crate::core::CHROME_ARGS
250            .iter()
251            .map(|argument| (*argument).to_owned())
252            .collect();
253        let mut actual = legacy.args;
254        expected.sort();
255        actual.sort();
256        assert_eq!(actual, expected);
257    }
258
259    #[test]
260    fn rejects_unknown_restrictions() {
261        let error = resolve_restrictions(&["no-such-thing"])
262            .unwrap_err()
263            .to_string();
264        assert!(
265            error.starts_with(
266                "Unknown launch restriction \"no-such-thing\". Expected one of no-first-run"
267            ),
268            "{error}"
269        );
270        assert!(error.contains("legacy-defaults"), "{error}");
271    }
272
273    #[test]
274    fn merges_repeated_feature_switches() {
275        assert_eq!(
276            merge_feature_switches(&[
277                "--disable-features=A,B",
278                "--lang=en-US",
279                "--disable-features=B,,C",
280                "--enable-blink-features=X",
281                "--disable-featuresX=1",
282            ]),
283            [
284                "--disable-features=A,B,C",
285                "--lang=en-US",
286                "--enable-blink-features=X",
287                "--disable-featuresX=1",
288            ]
289        );
290    }
291}