Skip to main content

symbi_runtime/toolclad/
browser_state.rs

1//! Browser state types for CDP-based browser sessions.
2
3use serde::{Deserialize, Serialize};
4
5/// Page state inferred from CDP inspection.
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct PageState {
8    pub url: String,
9    pub title: String,
10    pub domain: String,
11    pub has_forms: bool,
12    pub is_authenticated: bool,
13    pub page_loaded: bool,
14    pub tab_count: u32,
15}
16
17/// Browser lifecycle status.
18#[derive(Debug, Clone, PartialEq)]
19pub enum BrowserStatus {
20    Connecting,
21    Ready,
22    Busy,
23    TimedOut,
24    Terminated,
25}
26
27/// Tab info from Chrome debug endpoint.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TabInfo {
30    pub id: String,
31    pub url: String,
32    pub title: String,
33    #[serde(rename = "type")]
34    pub tab_type: String,
35    #[serde(rename = "webSocketDebuggerUrl")]
36    pub ws_url: Option<String>,
37}
38
39/// Browser scope checker -- validates URLs against allowed/blocked domains.
40pub struct BrowserScopeChecker {
41    pub allowed_domains: Vec<String>,
42    pub blocked_domains: Vec<String>,
43    pub allow_external: bool,
44}
45
46impl BrowserScopeChecker {
47    pub fn new(scope: &super::manifest::BrowserScopeDef) -> Self {
48        Self {
49            allowed_domains: scope.allowed_domains.clone(),
50            blocked_domains: scope.blocked_domains.clone(),
51            allow_external: scope.allow_external,
52        }
53    }
54
55    /// Check if a URL is allowed by scope rules.
56    pub fn check_url(&self, url: &str) -> Result<(), String> {
57        let domain =
58            extract_domain(url).ok_or_else(|| format!("Cannot extract domain from: {}", url))?;
59        self.check_domain(&domain)
60    }
61
62    /// Check if a domain is allowed.
63    pub fn check_domain(&self, domain: &str) -> Result<(), String> {
64        // Check blocked first
65        for blocked in &self.blocked_domains {
66            if domain_matches(domain, blocked) {
67                return Err(format!(
68                    "Domain '{}' is blocked by scope rule '{}'",
69                    domain, blocked
70                ));
71            }
72        }
73
74        // If no allowed list, check allow_external
75        if self.allowed_domains.is_empty() {
76            return if self.allow_external {
77                Ok(())
78            } else {
79                Err("No allowed domains configured and allow_external is false".to_string())
80            };
81        }
82
83        // Check allowed
84        for allowed in &self.allowed_domains {
85            if domain_matches(domain, allowed) {
86                return Ok(());
87            }
88        }
89
90        if self.allow_external {
91            Ok(())
92        } else {
93            Err(format!(
94                "Domain '{}' not in allowed domains: {}",
95                domain,
96                self.allowed_domains.join(", ")
97            ))
98        }
99    }
100}
101
102/// Extract domain from a URL.
103fn extract_domain(url: &str) -> Option<String> {
104    let after_scheme = url.split("://").nth(1)?;
105    let domain = after_scheme.split('/').next()?;
106    let domain = domain.split(':').next()?; // strip port
107    Some(domain.to_string())
108}
109
110/// Check if a domain matches a pattern (supports wildcard *.example.com).
111fn domain_matches(domain: &str, pattern: &str) -> bool {
112    if pattern.starts_with("*.") {
113        let suffix = &pattern[1..]; // .example.com
114        domain.ends_with(suffix) || domain == &pattern[2..]
115    } else {
116        domain == pattern
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::toolclad::manifest::BrowserScopeDef;
124
125    #[test]
126    fn test_extract_domain() {
127        assert_eq!(
128            extract_domain("https://example.com/path"),
129            Some("example.com".to_string())
130        );
131        assert_eq!(
132            extract_domain("http://localhost:8080/"),
133            Some("localhost".to_string())
134        );
135        assert_eq!(extract_domain("not-a-url"), None);
136    }
137
138    #[test]
139    fn test_domain_matches_exact() {
140        assert!(domain_matches("example.com", "example.com"));
141        assert!(!domain_matches("other.com", "example.com"));
142    }
143
144    #[test]
145    fn test_domain_matches_wildcard() {
146        assert!(domain_matches("sub.example.com", "*.example.com"));
147        assert!(domain_matches("example.com", "*.example.com"));
148        assert!(!domain_matches("evil.com", "*.example.com"));
149    }
150
151    #[test]
152    fn test_scope_checker_allowed() {
153        let scope = BrowserScopeDef {
154            allowed_domains: vec!["*.example.com".to_string()],
155            blocked_domains: vec![],
156            allow_external: false,
157        };
158        let checker = BrowserScopeChecker::new(&scope);
159        assert!(checker.check_url("https://app.example.com/page").is_ok());
160        assert!(checker.check_url("https://evil.com/page").is_err());
161    }
162
163    #[test]
164    fn test_scope_checker_blocked() {
165        let scope = BrowserScopeDef {
166            allowed_domains: vec!["*.example.com".to_string()],
167            blocked_domains: vec!["admin.example.com".to_string()],
168            allow_external: false,
169        };
170        let checker = BrowserScopeChecker::new(&scope);
171        assert!(checker.check_url("https://app.example.com").is_ok());
172        assert!(checker.check_url("https://admin.example.com").is_err());
173    }
174
175    #[test]
176    fn test_scope_checker_allow_external() {
177        let scope = BrowserScopeDef {
178            allowed_domains: vec!["example.com".to_string()],
179            blocked_domains: vec![],
180            allow_external: true,
181        };
182        let checker = BrowserScopeChecker::new(&scope);
183        assert!(checker.check_url("https://example.com").is_ok());
184        assert!(checker.check_url("https://other.com").is_ok()); // allow_external
185    }
186
187    #[test]
188    fn test_scope_checker_no_external() {
189        let scope = BrowserScopeDef {
190            allowed_domains: vec![],
191            blocked_domains: vec![],
192            allow_external: false,
193        };
194        let checker = BrowserScopeChecker::new(&scope);
195        assert!(checker.check_url("https://any.com").is_err());
196    }
197
198    #[test]
199    fn test_page_state_default() {
200        let ps = PageState::default();
201        assert!(!ps.has_forms);
202        assert!(!ps.is_authenticated);
203        assert!(ps.url.is_empty());
204    }
205}