1use slicc_browsers::ProtectedOrigins;
2use std::sync::OnceLock;
3use url::Url;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum Opener {
7 None,
8 Protected,
9 Unprotected,
10 Unknown,
11}
12
13#[derive(Clone, Debug)]
14pub struct Protected {
15 rules: ProtectedOrigins,
16 extra: Vec<String>,
17 extra_hosts: Vec<String>,
18}
19
20fn catalog_rules() -> &'static ProtectedOrigins {
21 static RULES: OnceLock<ProtectedOrigins> = OnceLock::new();
22 RULES.get_or_init(|| slicc_browsers::catalog().protected_origins)
23}
24
25pub fn refusal() -> &'static str {
26 &catalog_rules().refusal
27}
28
29impl Default for Protected {
30 fn default() -> Self {
31 Self::new(&[])
32 }
33}
34
35impl Protected {
36 pub fn new(extra_origins: &[&str]) -> Self {
37 Self::with_rules(catalog_rules().clone(), extra_origins)
38 }
39
40 pub fn with_rules(rules: ProtectedOrigins, extra_origins: &[&str]) -> Self {
41 let parsed: Vec<Url> = extra_origins
42 .iter()
43 .filter_map(|text| parse(text))
44 .collect();
45 let extra = parsed.iter().filter_map(origin_of).collect();
46 let extra_hosts = parsed
47 .iter()
48 .filter(|url| url.host_str().is_some_and(|host| !host.is_empty()))
49 .map(host_of)
50 .collect();
51 Self {
52 rules,
53 extra,
54 extra_hosts,
55 }
56 }
57
58 pub fn refusal(&self) -> &str {
59 &self.rules.refusal
60 }
61
62 pub fn url(&self, text: &str) -> bool {
63 let Some(url) = self.unwrap(parse(text)) else {
64 return false;
65 };
66 let scheme = url.scheme();
67 if self.rules.any_schemes.iter().any(|item| item == scheme) {
68 return true;
69 }
70 if self.rules.schemes.iter().any(|item| item == scheme) {
71 let host = host_of(&url);
72 if self.rules.hosts.contains(&host) {
73 return true;
74 }
75 if self.rules.domains.iter().any(|domain| under(&host, domain)) {
76 return true;
77 }
78 }
79 origin_of(&url).is_some_and(|origin| self.extra.contains(&origin))
80 }
81
82 pub fn target(&self, text: &str, previous: bool, opener: Opener) -> bool {
83 if self.inheriting(text) {
84 return previous || matches!(opener, Opener::Protected | Opener::Unknown);
85 }
86 self.url(text)
87 }
88
89 pub fn cookie_domain(&self, domain: &str) -> bool {
90 let mut domain = domain.to_ascii_lowercase();
91 if let Some(rest) = domain.strip_prefix('.') {
92 domain = rest.to_string();
93 }
94 if let Some(rest) = domain.strip_suffix('.') {
95 domain = rest.to_string();
96 }
97 if domain.is_empty() {
98 return false;
99 }
100 if self.rules.domains.iter().any(|item| under(&domain, item)) {
101 return true;
102 }
103 self.rules
104 .domains
105 .iter()
106 .chain(self.rules.hosts.iter())
107 .chain(self.extra_hosts.iter())
108 .any(|host| under(host, &domain))
109 }
110
111 pub fn storage_key(&self, key: &str) -> bool {
112 let mut parts = key.split('^');
113 let first = parts.next().unwrap_or("");
114 if self.url(first) {
115 return true;
116 }
117 parts
118 .filter_map(|part| part.strip_prefix('0'))
119 .any(|site| self.url(site))
120 }
121
122 pub fn pattern(&self, pattern: &str) -> bool {
123 let lowered = pattern.to_ascii_lowercase();
124 if self
125 .rules
126 .any_schemes
127 .iter()
128 .any(|scheme| lowered.starts_with(&format!("{scheme}:")))
129 {
130 return true;
131 }
132 let names: Vec<&String> = self
133 .rules
134 .domains
135 .iter()
136 .chain(self.rules.hosts.iter())
137 .chain(self.extra_hosts.iter())
138 .collect();
139 if pattern_hosts(&lowered)
140 .iter()
141 .any(|host| names.iter().any(|name| host_reaches(host, name)))
142 {
143 return true;
144 }
145 let literal: String = pattern
146 .chars()
147 .take_while(|ch| !matches!(ch, '*' | '?' | '{' | '}' | '[' | ']' | '\\'))
148 .collect();
149 literal.contains("://") && self.url(&literal)
150 }
151
152 fn inheriting(&self, text: &str) -> bool {
153 if text.is_empty() {
154 return true;
155 }
156 let Some(url) = parse(text) else {
157 return false;
158 };
159 url.scheme() == "about"
160 && self
161 .rules
162 .inheriting
163 .iter()
164 .any(|item| *item == format!("about:{}", url.path().to_ascii_lowercase()))
165 }
166
167 fn unwrap(&self, mut url: Option<Url>) -> Option<Url> {
168 let mut depth = 0;
169 while let Some(current) = url.as_ref() {
170 if depth >= 8
171 || !self
172 .rules
173 .inner_schemes
174 .iter()
175 .any(|item| item == current.scheme())
176 {
177 break;
178 }
179 let rest = current.as_str()[current.scheme().len() + 1..].to_string();
180 url = parse(&rest);
181 depth += 1;
182 }
183 url
184 }
185}
186
187fn pattern_hosts(pattern: &str) -> Vec<String> {
188 if let Some(index) = pattern.find("://") {
189 let scheme = &pattern[..index];
190 if !scheme.is_empty()
191 && scheme.chars().all(|ch| {
192 ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.' | '*' | '{' | '}' | ',')
193 })
194 {
195 let rest = &pattern[index + 3..];
196 let end = rest.find(['/', '#', '?']).unwrap_or(rest.len());
197 return authority_host(&rest[..end]).into_iter().collect();
198 }
199 }
200 let end = pattern.find(['#', '?']).unwrap_or(pattern.len());
201 pattern[..end]
202 .split('/')
203 .filter_map(authority_host)
204 .filter(|host| host.contains('.'))
205 .collect()
206}
207
208fn authority_host(authority: &str) -> Option<String> {
209 let host = authority.rsplit('@').next().unwrap_or(authority);
210 let host = match host.rfind(':') {
211 Some(index)
212 if !host.starts_with('[')
213 && host[index + 1..]
214 .chars()
215 .all(|ch| ch.is_ascii_digit() || ch == '*') =>
216 {
217 &host[..index]
218 }
219 _ => host,
220 };
221 let host = host.strip_suffix('.').unwrap_or(host);
222 if host.is_empty()
223 || host
224 .chars()
225 .all(|ch| matches!(ch, '*' | '?' | '{' | '}' | ','))
226 {
227 return None;
228 }
229 Some(host.to_string())
230}
231
232fn host_reaches(glob: &str, name: &str) -> bool {
233 let first: String = glob
234 .split('.')
235 .next()
236 .unwrap_or("")
237 .chars()
238 .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
239 .collect();
240 let label = if first.is_empty() {
241 "x".to_string()
242 } else {
243 first
244 };
245 [
246 name.to_string(),
247 format!("x.{name}"),
248 format!("{label}.{name}"),
249 ]
250 .iter()
251 .any(|candidate| host_glob(glob.as_bytes(), candidate.as_bytes()))
252}
253
254fn host_glob(glob: &[u8], text: &[u8]) -> bool {
255 let Some((&first, rest)) = glob.split_first() else {
256 return text.is_empty();
257 };
258 match first {
259 b'*' => (0..=text.len()).any(|taken| host_glob(rest, &text[taken..])),
260 b'{' => match rest.iter().position(|ch| *ch == b'}') {
261 Some(close) => {
262 (0..=text.len()).any(|taken| host_glob(&rest[close + 1..], &text[taken..]))
263 }
264 None => text.first() == Some(&b'{') && host_glob(rest, &text[1..]),
265 },
266 b'?' => !text.is_empty() && host_glob(rest, &text[1..]),
267 b'\\' => match rest.split_first() {
268 Some((&literal, after)) => {
269 text.first() == Some(&literal) && host_glob(after, &text[1..])
270 }
271 None => text.is_empty(),
272 },
273 literal => text.first() == Some(&literal) && host_glob(rest, &text[1..]),
274 }
275}
276
277fn parse(text: &str) -> Option<Url> {
278 Url::parse(text).ok()
279}
280
281fn host_of(url: &Url) -> String {
282 let host = url.host_str().unwrap_or("").to_ascii_lowercase();
283 match host.strip_suffix('.') {
284 Some(stripped) => stripped.to_string(),
285 None => host,
286 }
287}
288
289fn origin_of(url: &Url) -> Option<String> {
290 url.host_str().filter(|host| !host.is_empty())?;
291 let host = host_of(url);
292 Some(match url.port() {
293 Some(port) => format!("{}://{host}:{port}", url.scheme()),
294 None => format!("{}://{host}", url.scheme()),
295 })
296}
297
298fn under(host: &str, domain: &str) -> bool {
299 host == domain || host.ends_with(&format!(".{domain}"))
300}
301
302#[cfg(test)]
303mod tests {
304 use super::{Opener, Protected};
305 use slicc_browsers::protected::{
306 vectors, OPENER_PROTECTED, OPENER_UNKNOWN, OPENER_UNPROTECTED,
307 };
308
309 fn guard() -> Protected {
310 let vectors = vectors();
311 let origins: Vec<&str> = vectors.origins.iter().map(String::as_str).collect();
312 Protected::new(&origins)
313 }
314
315 #[test]
316 fn every_url_vector_matches_slicc_browsers() {
317 let guard = guard();
318 for vector in vectors().urls {
319 assert_eq!(guard.url(&vector.url), vector.protected, "{:?}", vector.url);
320 }
321 }
322
323 #[test]
324 fn every_target_vector_matches_slicc_browsers() {
325 let guard = guard();
326 for vector in vectors().targets {
327 let opener = match vector.opener.as_deref() {
328 None => Opener::None,
329 Some(OPENER_PROTECTED) => Opener::Protected,
330 Some(OPENER_UNPROTECTED) => Opener::Unprotected,
331 Some(OPENER_UNKNOWN) => Opener::Unknown,
332 Some(other) => panic!("unknown opener {other}"),
333 };
334 assert_eq!(
335 guard.target(&vector.url, vector.previous, opener),
336 vector.protected,
337 "{vector:?}"
338 );
339 }
340 }
341
342 #[test]
343 fn every_cookie_and_storage_vector_matches_slicc_browsers() {
344 let guard = guard();
345 let all = vectors();
346 for vector in all.cookie_domains {
347 assert_eq!(
348 guard.cookie_domain(&vector.domain),
349 vector.protected,
350 "{vector:?}"
351 );
352 }
353 for vector in all.storage_keys {
354 assert_eq!(
355 guard.storage_key(&vector.key),
356 vector.protected,
357 "{vector:?}"
358 );
359 }
360 }
361
362 #[test]
363 fn patterns_that_name_seven_or_the_vault_are_protected() {
364 let guard = Protected::default();
365 for pattern in [
366 "https://vault.sliccy.ai/*",
367 "**://*.sliccy.ai/**",
368 "*sliccy.ai*",
369 "https://SEVEN.SLICCY.AI/api/*",
370 "https://www.sliccy.ai/auth/callback*",
371 "devtools://*",
372 "https://sliccy.ai/",
373 ] {
374 assert!(guard.pattern(pattern), "{pattern}");
375 }
376 for pattern in [
377 "**://*.sliccy.ai/**",
378 "**/*.sliccy.ai/**",
379 "https://seven.sliccy.*/x",
380 "https://user@vault.sliccy.ai:443/x",
381 "https://vault.sliccy.ai./x",
382 "*://{seven,other}.sliccy.ai/*",
383 ] {
384 assert!(guard.pattern(pattern), "{pattern}");
385 }
386 for pattern in [
387 "*",
388 "**/*",
389 "**/api/users",
390 "https://*/api",
391 "https://example.com/*",
392 "https://vault-sliccy.example/*",
393 "https://notsliccy.ai/*",
394 "https://vault.sliccy.ai.example/*",
395 "https://vault-sliccy.ai/*",
396 "**/x?next=https://seven.sliccy.ai/",
397 "https://example.com/?next=https://seven.sliccy.ai/",
398 "*/blocked*",
399 "**/api/*/details",
400 ] {
401 assert!(!guard.pattern(pattern), "{pattern}");
402 }
403 assert_eq!(
404 guard.refusal(),
405 "slicc: refused: target is on seven's origin"
406 );
407 }
408}