symbi_runtime/toolclad/
browser_state.rs1use serde::{Deserialize, Serialize};
4
5#[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#[derive(Debug, Clone, PartialEq)]
19pub enum BrowserStatus {
20 Connecting,
21 Ready,
22 Busy,
23 TimedOut,
24 Terminated,
25}
26
27#[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
39pub 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 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 pub fn check_domain(&self, domain: &str) -> Result<(), String> {
64 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 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 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
102fn 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()?; Some(domain.to_string())
108}
109
110fn domain_matches(domain: &str, pattern: &str) -> bool {
112 if pattern.starts_with("*.") {
113 let suffix = &pattern[1..]; 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()); }
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}