Skip to main content

rightkit_browser/
config.rs

1use crate::error::{BrowserError, Result};
2use crate::policy::{AdmissionHook, EventSink, NetworkPolicy};
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6/// Where the browser keeps cookies, storage, and logins.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub enum ProfileSpec {
9    /// Throwaway profile deleted when the session drops (default).
10    Temporary,
11    /// Named persistent profile at `<root>/<name>`. One live session per name;
12    /// a second launch fails with `ProfileInUse` instead of corrupting state.
13    Named { root: PathBuf, name: String },
14}
15
16#[derive(Clone)]
17pub struct LaunchOptions {
18    pub headless: bool,
19    /// Passes `--mute-audio`. Default true.
20    pub mute_audio: bool,
21    pub profile: ProfileSpec,
22    /// Explicit browser executable. When `None`, launch resolves Chrome for Testing with
23    /// [`chrome_for_testing_path`].
24    pub chrome_path: Option<PathBuf>,
25    /// Opt-in: when Chrome for Testing is not installed, fall back to the system browser
26    /// ([`find_chrome`]). Off by default: regular Google Chrome on macOS leaves an APFS
27    /// code-sign clone of its bundle behind for every instance that does not shut down cleanly.
28    pub allow_system_chrome: bool,
29    pub viewport: (u32, u32),
30    pub launch_timeout: Duration,
31    /// Extra Chrome flags, with or without leading `--`.
32    pub extra_args: Vec<String>,
33    /// Defaults to `<profile>/downloads` for temporary profiles and
34    /// `<profile>/rightkit-downloads` for named ones.
35    pub download_dir: Option<PathBuf>,
36    /// When set, `upload` refuses files outside this root.
37    pub upload_root: Option<PathBuf>,
38    /// Gates every navigation, input, and script action before it has any effect.
39    /// `None` admits everything (trusted callers).
40    pub admission: Option<AdmissionHook>,
41    /// Network/SSRF policy. Default blocks loopback, link-local, private ranges,
42    /// `localhost`, and `file:` unless allowed.
43    pub network: NetworkPolicy,
44    /// Receives start/stop/crash/denied lifecycle events (session id, reason, time).
45    pub on_event: Option<EventSink>,
46    /// Windows: the caller's job object (see [`Self::windows_job`]). Chromium and every
47    /// helper it starts are bound to it; a clone of these options keeps the duplicate open.
48    #[cfg(windows)]
49    pub windows_job: Option<std::sync::Arc<std::os::windows::io::OwnedHandle>>,
50}
51
52impl std::fmt::Debug for LaunchOptions {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("LaunchOptions")
55            .field("headless", &self.headless)
56            .field("profile", &self.profile)
57            .field("network", &self.network)
58            .field("admission", &self.admission.is_some())
59            .finish_non_exhaustive()
60    }
61}
62
63impl Default for LaunchOptions {
64    fn default() -> Self {
65        Self {
66            headless: true,
67            mute_audio: true,
68            profile: ProfileSpec::Temporary,
69            chrome_path: None,
70            allow_system_chrome: false,
71            viewport: (1280, 800),
72            launch_timeout: Duration::from_secs(60),
73            extra_args: Vec::new(),
74            download_dir: None,
75            upload_root: None,
76            admission: None,
77            network: NetworkPolicy::default(),
78            on_event: None,
79            #[cfg(windows)]
80            windows_job: None,
81        }
82    }
83}
84
85impl LaunchOptions {
86    /// Opt in to the system Chrome/Chromium/Edge when Chrome for Testing is missing.
87    pub fn system_chrome(mut self) -> Self {
88        self.allow_system_chrome = true;
89        self
90    }
91    pub fn headed(mut self) -> Self {
92        self.headless = false;
93        self
94    }
95    pub fn admission(mut self, hook: AdmissionHook) -> Self {
96        self.admission = Some(hook);
97        self
98    }
99    pub fn network(mut self, policy: NetworkPolicy) -> Self {
100        self.network = policy;
101        self
102    }
103    pub fn on_event(
104        mut self,
105        f: impl Fn(&crate::policy::BrowserEvent) + Send + Sync + 'static,
106    ) -> Self {
107        self.on_event = Some(std::sync::Arc::new(f));
108        self
109    }
110    /// Windows: bind Chromium to the caller's job object (an in-process host's own job).
111    /// Chromium joins it before running its first instruction, beneath RightKit's own
112    /// kill-on-close jobs, none of which allow breakaway: terminating or closing the
113    /// caller's job kills the browser and all of its helpers. The handle is duplicated; the
114    /// session closes its duplicate as soon as Chromium has been bound.
115    #[cfg(windows)]
116    pub fn windows_job(
117        mut self,
118        job: std::os::windows::io::BorrowedHandle<'_>,
119    ) -> std::io::Result<Self> {
120        self.windows_job = Some(std::sync::Arc::new(job.try_clone_to_owned()?));
121        Ok(self)
122    }
123    pub fn named_profile(mut self, root: impl Into<PathBuf>, name: impl Into<String>) -> Self {
124        self.profile = ProfileSpec::Named {
125            root: root.into(),
126            name: name.into(),
127        };
128        self
129    }
130}
131
132pub(crate) fn validate_profile_name(name: &str) -> Result<()> {
133    let ok = !name.is_empty()
134        && name.len() <= 64
135        && !name.starts_with('.')
136        && name
137            .chars()
138            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
139    if ok {
140        Ok(())
141    } else {
142        Err(BrowserError::Profile(format!(
143            "invalid profile name '{name}'"
144        )))
145    }
146}
147
148/// Environment variable naming an explicit Chrome for Testing executable.
149pub const CHROME_FOR_TESTING_ENV: &str = "RIGHTKIT_CHROME_FOR_TESTING";
150
151/// How to get Chrome for Testing; part of [`BrowserError::ChromeForTestingNotFound`].
152pub const CHROME_FOR_TESTING_INSTALL_HINT: &str = "Install Chrome for Testing (any one of):\n  \
153pnpm dlx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n  \
154pnpm exec playwright install chromium\n\
155or point RIGHTKIT_CHROME_FOR_TESTING at its executable. Regular Google Chrome is used only \
156with LaunchOptions::system_chrome(): on macOS each killed instance leaks a code-sign clone.";
157
158const MAC_CFT_APP: &str = "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing";
159
160fn home_dir() -> Option<PathBuf> {
161    std::env::var_os("HOME")
162        .or_else(|| std::env::var_os("USERPROFILE"))
163        .map(PathBuf::from)
164}
165
166fn version_key(name: &str) -> Vec<u64> {
167    name.split(|c: char| !c.is_ascii_digit())
168        .filter(|p| !p.is_empty())
169        .filter_map(|p| p.parse().ok())
170        .collect()
171}
172
173/// Children of `root` whose names start with one of `prefixes`, newest version first.
174fn versioned_children(root: &Path, prefixes: &[&str]) -> Vec<PathBuf> {
175    let mut names: Vec<String> = std::fs::read_dir(root)
176        .map(|rd| {
177            rd.filter_map(|e| e.ok())
178                .map(|e| e.file_name().to_string_lossy().into_owned())
179                .filter(|n| prefixes.iter().any(|p| n.starts_with(p)))
180                .collect()
181        })
182        .unwrap_or_default();
183    names.sort_by(|a, b| version_key(b).cmp(&version_key(a)).then_with(|| b.cmp(a)));
184    names.into_iter().map(|n| root.join(n)).collect()
185}
186
187/// Known Chrome for Testing install locations, in resolution order: app installs, then
188/// Puppeteer's cache (`PUPPETEER_CACHE_DIR`), then Playwright's (`PLAYWRIGHT_BROWSERS_PATH`),
189/// newest version first within each cache.
190pub fn chrome_for_testing_candidates() -> Vec<PathBuf> {
191    let home = home_dir().unwrap_or_default();
192    let env_path = |k: &str| {
193        std::env::var_os(k)
194            .filter(|v| !v.is_empty())
195            .map(PathBuf::from)
196    };
197    let puppeteer = env_path("PUPPETEER_CACHE_DIR")
198        .unwrap_or_else(|| home.join(".cache").join("puppeteer"))
199        .join("chrome");
200    let mut out = Vec::new();
201    if cfg!(target_os = "macos") {
202        out.push(Path::new("/Applications").join(MAC_CFT_APP));
203        out.push(home.join("Applications").join(MAC_CFT_APP));
204        let subs: [&str; 2] = if cfg!(target_arch = "aarch64") {
205            ["chrome-mac-arm64", "chrome-mac-x64"]
206        } else {
207            ["chrome-mac-x64", "chrome-mac-arm64"]
208        };
209        let playwright = env_path("PLAYWRIGHT_BROWSERS_PATH")
210            .unwrap_or_else(|| home.join("Library/Caches/ms-playwright"));
211        for dir in versioned_children(&puppeteer, &["mac_arm-", "mac-"])
212            .into_iter()
213            .chain(versioned_children(&playwright, &["chromium-"]))
214        {
215            for sub in subs {
216                out.push(dir.join(sub).join(MAC_CFT_APP));
217            }
218        }
219    } else if cfg!(target_os = "windows") {
220        let local = env_path("LOCALAPPDATA").unwrap_or_else(|| home.join(r"AppData\Local"));
221        for dir in versioned_children(&puppeteer, &["win64-", "win32-"]) {
222            out.push(dir.join("chrome-win64").join("chrome.exe"));
223            out.push(dir.join("chrome-win32").join("chrome.exe"));
224        }
225        let playwright =
226            env_path("PLAYWRIGHT_BROWSERS_PATH").unwrap_or_else(|| local.join("ms-playwright"));
227        for dir in versioned_children(&playwright, &["chromium-"]) {
228            out.push(dir.join("chrome-win64").join("chrome.exe"));
229        }
230    } else {
231        for dir in versioned_children(&puppeteer, &["linux-", "linux_arm-"]) {
232            out.push(dir.join("chrome-linux64").join("chrome"));
233            out.push(dir.join("chrome-linux-arm64").join("chrome"));
234        }
235        let playwright = env_path("PLAYWRIGHT_BROWSERS_PATH")
236            .unwrap_or_else(|| home.join(".cache").join("ms-playwright"));
237        for dir in versioned_children(&playwright, &["chromium-"]) {
238            out.push(dir.join("chrome-linux64").join("chrome"));
239        }
240    }
241    out
242}
243
244/// Resolve Chrome for Testing: `explicit`, then `RIGHTKIT_CHROME_FOR_TESTING`, then
245/// [`chrome_for_testing_candidates`]. Errors with install instructions; never returns
246/// regular Chrome.
247pub fn chrome_for_testing_path(explicit: Option<&Path>) -> Result<PathBuf> {
248    let missing = |what: String| {
249        BrowserError::ChromeForTestingNotFound(format!("{what}\n{CHROME_FOR_TESTING_INSTALL_HINT}"))
250    };
251    if let Some(p) = explicit {
252        return if p.exists() {
253            Ok(p.to_path_buf())
254        } else {
255            Err(missing(format!(
256                "explicit Chrome for Testing executable does not exist: {}",
257                p.display()
258            )))
259        };
260    }
261    if let Some(p) = std::env::var_os(CHROME_FOR_TESTING_ENV).filter(|v| !v.is_empty()) {
262        let p = PathBuf::from(p);
263        return if p.exists() {
264            Ok(p)
265        } else {
266            Err(missing(format!(
267                "{CHROME_FOR_TESTING_ENV} points at a missing file: {}",
268                p.display()
269            )))
270        };
271    }
272    let candidates = chrome_for_testing_candidates();
273    if let Some(p) = candidates.iter().find(|p| p.exists()) {
274        return Ok(p.clone());
275    }
276    let searched: Vec<String> = candidates
277        .iter()
278        .map(|p| format!("  {}", p.display()))
279        .collect();
280    Err(missing(format!(
281        "Chrome for Testing is not installed; searched:\n{}",
282        if searched.is_empty() {
283            "  (nothing)".to_string()
284        } else {
285            searched.join("\n")
286        }
287    )))
288}
289
290/// The executable a launch uses: `opts.chrome_path` as given, else Chrome for Testing, else
291/// (only with [`LaunchOptions::system_chrome`]) the system browser.
292pub(crate) fn resolve_executable(opts: &LaunchOptions) -> Result<PathBuf> {
293    if let Some(p) = &opts.chrome_path {
294        return Ok(p.clone());
295    }
296    match chrome_for_testing_path(None) {
297        Ok(p) => Ok(p),
298        Err(e) if opts.allow_system_chrome => find_chrome().ok_or(e),
299        Err(e) => Err(e),
300    }
301}
302
303/// System Chrome, Chromium, then Edge. `CHROME` env overrides. Launch uses this only with
304/// [`LaunchOptions::system_chrome`]; prefer [`chrome_for_testing_path`].
305pub fn find_chrome() -> Option<PathBuf> {
306    if let Ok(p) = std::env::var("CHROME") {
307        let p = PathBuf::from(p);
308        if p.exists() {
309            return Some(p);
310        }
311    }
312    let candidates: &[&str] = if cfg!(target_os = "windows") {
313        &[
314            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
315            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
316            r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
317            r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
318        ]
319    } else if cfg!(target_os = "macos") {
320        &[
321            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
322            "/Applications/Chromium.app/Contents/MacOS/Chromium",
323            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
324        ]
325    } else {
326        &[
327            "/usr/bin/google-chrome",
328            "/usr/bin/chromium",
329            "/usr/bin/chromium-browser",
330            "/usr/bin/microsoft-edge",
331        ]
332    };
333    candidates
334        .iter()
335        .map(PathBuf::from)
336        .find(|p| Path::new(p).exists())
337}
338
339/// Chrome flags a caller may not pass: they would redirect the profile to the
340/// user's real browser data or expose the debugging endpoint outside this session.
341pub(crate) fn validate_extra_args(args: &[String]) -> Result<()> {
342    const FORBIDDEN: &[&str] = &[
343        "user-data-dir",
344        "profile-directory",
345        "remote-debugging-port",
346        "remote-debugging-address",
347        "remote-debugging-pipe",
348        "remote-allow-origins",
349        "disable-web-security",
350        "incognito-bypass",
351    ];
352    for a in args {
353        let name = a.trim_start_matches('-').split('=').next().unwrap_or("");
354        if FORBIDDEN.contains(&name) {
355            return Err(BrowserError::Invalid(format!(
356                "chrome flag '--{name}' is managed by the session"
357            )));
358        }
359    }
360    Ok(())
361}
362
363/// Refuse roots that are (or sit inside) a real browser's own user-data directory.
364pub(crate) fn reject_real_browser_profile(root: &Path) -> Result<()> {
365    let mut real: Vec<PathBuf> = Vec::new();
366    if let Some(home) = std::env::var_os("HOME")
367        .or_else(|| std::env::var_os("USERPROFILE"))
368        .map(PathBuf::from)
369    {
370        for rel in [
371            "Library/Application Support/Google/Chrome",
372            "Library/Application Support/Chromium",
373            "Library/Application Support/Microsoft Edge",
374            ".config/google-chrome",
375            ".config/chromium",
376            ".config/microsoft-edge",
377        ] {
378            real.push(home.join(rel));
379        }
380    }
381    if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
382        for rel in [
383            r"Google\Chrome\User Data",
384            r"Chromium\User Data",
385            r"Microsoft\Edge\User Data",
386        ] {
387            real.push(local.join(rel));
388        }
389    }
390    std::fs::create_dir_all(root)?;
391    let root = root.canonicalize()?;
392    for r in real {
393        if let Ok(r) = r.canonicalize() {
394            if root.starts_with(&r) {
395                return Err(BrowserError::Profile(
396                    "refusing to use the user's real browser profile".into(),
397                ));
398            }
399        }
400    }
401    Ok(())
402}