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    pub chrome_path: Option<PathBuf>,
23    pub viewport: (u32, u32),
24    pub launch_timeout: Duration,
25    /// Extra Chrome flags, with or without leading `--`.
26    pub extra_args: Vec<String>,
27    /// Defaults to `<profile>/downloads` for temporary profiles and
28    /// `<profile>/rightkit-downloads` for named ones.
29    pub download_dir: Option<PathBuf>,
30    /// When set, `upload` refuses files outside this root.
31    pub upload_root: Option<PathBuf>,
32    /// Gates every navigation, input, and script action before it has any effect.
33    /// `None` admits everything (trusted callers).
34    pub admission: Option<AdmissionHook>,
35    /// Network/SSRF policy. Default blocks loopback, link-local, private ranges,
36    /// `localhost`, and `file:` unless allowed.
37    pub network: NetworkPolicy,
38    /// Receives start/stop/denied lifecycle events.
39    pub on_event: Option<EventSink>,
40}
41
42impl std::fmt::Debug for LaunchOptions {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("LaunchOptions")
45            .field("headless", &self.headless)
46            .field("profile", &self.profile)
47            .field("network", &self.network)
48            .field("admission", &self.admission.is_some())
49            .finish_non_exhaustive()
50    }
51}
52
53impl Default for LaunchOptions {
54    fn default() -> Self {
55        Self {
56            headless: true,
57            mute_audio: true,
58            profile: ProfileSpec::Temporary,
59            chrome_path: None,
60            viewport: (1280, 800),
61            launch_timeout: Duration::from_secs(60),
62            extra_args: Vec::new(),
63            download_dir: None,
64            upload_root: None,
65            admission: None,
66            network: NetworkPolicy::default(),
67            on_event: None,
68        }
69    }
70}
71
72impl LaunchOptions {
73    pub fn headed(mut self) -> Self {
74        self.headless = false;
75        self
76    }
77    pub fn admission(mut self, hook: AdmissionHook) -> Self {
78        self.admission = Some(hook);
79        self
80    }
81    pub fn network(mut self, policy: NetworkPolicy) -> Self {
82        self.network = policy;
83        self
84    }
85    pub fn on_event(
86        mut self,
87        f: impl Fn(&crate::policy::BrowserEvent) + Send + Sync + 'static,
88    ) -> Self {
89        self.on_event = Some(std::sync::Arc::new(f));
90        self
91    }
92    pub fn named_profile(mut self, root: impl Into<PathBuf>, name: impl Into<String>) -> Self {
93        self.profile = ProfileSpec::Named {
94            root: root.into(),
95            name: name.into(),
96        };
97        self
98    }
99}
100
101pub(crate) fn validate_profile_name(name: &str) -> Result<()> {
102    let ok = !name.is_empty()
103        && name.len() <= 64
104        && !name.starts_with('.')
105        && name
106            .chars()
107            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
108    if ok {
109        Ok(())
110    } else {
111        Err(BrowserError::Profile(format!(
112            "invalid profile name '{name}'"
113        )))
114    }
115}
116
117/// System Chrome, Chromium, then Edge. `CHROME` env overrides.
118pub fn find_chrome() -> Option<PathBuf> {
119    if let Ok(p) = std::env::var("CHROME") {
120        let p = PathBuf::from(p);
121        if p.exists() {
122            return Some(p);
123        }
124    }
125    let candidates: &[&str] = if cfg!(target_os = "windows") {
126        &[
127            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
128            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
129            r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
130            r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
131        ]
132    } else if cfg!(target_os = "macos") {
133        &[
134            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
135            "/Applications/Chromium.app/Contents/MacOS/Chromium",
136            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
137        ]
138    } else {
139        &[
140            "/usr/bin/google-chrome",
141            "/usr/bin/chromium",
142            "/usr/bin/chromium-browser",
143            "/usr/bin/microsoft-edge",
144        ]
145    };
146    candidates
147        .iter()
148        .map(PathBuf::from)
149        .find(|p| Path::new(p).exists())
150}
151
152/// Chrome flags a caller may not pass: they would redirect the profile to the
153/// user's real browser data or expose the debugging endpoint outside this session.
154pub(crate) fn validate_extra_args(args: &[String]) -> Result<()> {
155    const FORBIDDEN: &[&str] = &[
156        "user-data-dir",
157        "profile-directory",
158        "remote-debugging-port",
159        "remote-debugging-address",
160        "remote-debugging-pipe",
161        "remote-allow-origins",
162        "disable-web-security",
163        "incognito-bypass",
164    ];
165    for a in args {
166        let name = a.trim_start_matches('-').split('=').next().unwrap_or("");
167        if FORBIDDEN.contains(&name) {
168            return Err(BrowserError::Invalid(format!(
169                "chrome flag '--{name}' is managed by the session"
170            )));
171        }
172    }
173    Ok(())
174}
175
176/// Refuse roots that are (or sit inside) a real browser's own user-data directory.
177pub(crate) fn reject_real_browser_profile(root: &Path) -> Result<()> {
178    let mut real: Vec<PathBuf> = Vec::new();
179    if let Some(home) = std::env::var_os("HOME")
180        .or_else(|| std::env::var_os("USERPROFILE"))
181        .map(PathBuf::from)
182    {
183        for rel in [
184            "Library/Application Support/Google/Chrome",
185            "Library/Application Support/Chromium",
186            "Library/Application Support/Microsoft Edge",
187            ".config/google-chrome",
188            ".config/chromium",
189            ".config/microsoft-edge",
190        ] {
191            real.push(home.join(rel));
192        }
193    }
194    if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
195        for rel in [
196            r"Google\Chrome\User Data",
197            r"Chromium\User Data",
198            r"Microsoft\Edge\User Data",
199        ] {
200            real.push(local.join(rel));
201        }
202    }
203    std::fs::create_dir_all(root)?;
204    let root = root.canonicalize()?;
205    for r in real {
206        if let Ok(r) = r.canonicalize() {
207            if root.starts_with(&r) {
208                return Err(BrowserError::Profile(
209                    "refusing to use the user's real browser profile".into(),
210                ));
211            }
212        }
213    }
214    Ok(())
215}