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 refusal_for_url(&self, text: &str) -> &str {
63 if self.rules.unparseable == "protected" && self.unparseable(text) {
64 &self.rules.unparseable_refusal
65 } else {
66 self.refusal()
67 }
68 }
69
70 pub fn unparseable(&self, text: &str) -> bool {
71 !matches!(text, "" | "null" | "://") && self.unwrap(parse(text)).is_none()
72 }
73
74 pub fn url(&self, text: &str) -> bool {
75 self.judge_url(text, false)
76 }
77
78 pub fn resource_key(&self, text: &str) -> Option<String> {
79 let mut url = self.unwrap(parse(text)).map(|url| self.rewrite_about(url))?;
80 url.set_username("").ok()?;
81 url.set_password(None).ok()?;
82 url.set_query(None);
83 url.set_fragment(None);
84 let path = url.path().trim_end_matches('/').to_string();
85 url.set_path(if path.is_empty() { "/" } else { &path });
86 Some(url.to_string())
87 }
88
89 pub fn redaction_schemes(&self) -> impl Iterator<Item = &str> {
90 self.rules.schemes.iter()
91 .chain(&self.rules.protected_schemes)
92 .chain(&self.rules.any_schemes)
93 .chain(&self.rules.inner_schemes)
94 .map(String::as_str)
95 .chain(self.extra.iter().filter_map(|origin| origin.split_once(':').map(|(scheme, _)| scheme)))
96 }
97
98 pub fn url_read_only(&self, text: &str) -> bool {
99 self.judge_url(text, true)
100 }
101
102 fn judge_url(&self, text: &str, read_only: bool) -> bool {
103 let Some(url) = self.unwrap(parse(text)).map(|url| self.rewrite_about(url)) else {
104 return !read_only && self.rules.unparseable == "protected" && !matches!(text, "" | "null" | "://");
105 };
106 let scheme = url.scheme();
107 if self
108 .rules
109 .protected_schemes
110 .iter()
111 .any(|item| item == scheme)
112 {
113 let pair = format!("{scheme}://{}", host_of(&url));
114 return !self.rules.scheme_exceptions.contains(&pair);
115 }
116 if self.rules.any_schemes.iter().any(|item| item == scheme) {
117 return true;
118 }
119 if self.rules.schemes.iter().any(|item| item == scheme) {
120 let host = host_of(&url);
121 if self.rules.hosts.contains(&host) {
122 return true;
123 }
124 if self.rules.domains.iter().any(|domain| under(&host, domain)) {
125 return true;
126 }
127 }
128 origin_of(&url).is_some_and(|origin| self.extra.contains(&origin))
129 }
130
131 pub fn target_type(&self, kind: &str) -> bool {
132 self.rules.target_types.iter().any(|item| item == kind)
133 }
134
135 pub fn error_page_url(&self, document_url: &str) -> bool {
136 self.rules.error_page.target == "previous"
137 && !self.rules.error_page.url.is_empty()
138 && document_url == self.rules.error_page.url
139 }
140
141 pub(crate) fn opaque_document(&self, text: &str, root: bool) -> bool {
142 root && (self.inheriting(text) || self.error_page_url(text))
143 || self.unwrap(parse(text)).map(|url| self.rewrite_about(url)).is_some_and(|url| {
144 self.rules.scheme_exceptions.contains(&format!("{}://{}", url.scheme(), host_of(&url)))
145 })
146 }
147
148 pub(crate) fn data_document(&self, text: &str) -> bool {
149 parse(text).is_some_and(|url| url.scheme() == "data")
150 }
151
152 pub fn error_document(&self, document_url: &str, origin: &str) -> bool {
153 self.error_page_url(document_url) && origin == "://"
154 }
155
156 pub fn error_redaction(&self) -> bool {
157 self.rules.error_page.redact == "origin"
158 }
159
160 pub fn target(&self, text: &str, previous: bool, opener: Opener) -> bool {
161 if self.inheriting(text) {
162 return previous || matches!(opener, Opener::Protected | Opener::Unknown);
163 }
164 self.url(text)
165 }
166
167 pub fn target_with_document(&self, text: &str, previous: bool, opener: Opener, document_url: Option<&str>, unreachable_url: Option<&str>, navigating: bool) -> bool {
168 if document_url.is_some_and(|url| self.error_page_url(url))
169 && unreachable_url == Some(text)
170 && !navigating
171 {
172 return previous;
173 }
174 self.target(text, previous, opener)
175 }
176
177 pub fn cookie_domain(&self, domain: &str) -> bool {
178 let mut domain = domain.to_ascii_lowercase();
179 if let Some(rest) = domain.strip_prefix('.') {
180 domain = rest.to_string();
181 }
182 if let Some(rest) = domain.strip_suffix('.') {
183 domain = rest.to_string();
184 }
185 if domain.is_empty() {
186 return false;
187 }
188 if self.rules.domains.iter().any(|item| under(&domain, item)) {
189 return true;
190 }
191 self.rules
192 .domains
193 .iter()
194 .chain(self.rules.hosts.iter())
195 .chain(self.extra_hosts.iter())
196 .any(|host| under(host, &domain))
197 }
198
199 pub fn cookie_scope(&self, domain: &str) -> bool {
200 canonical_cookie_domain(domain).is_none_or(|host| self.cookie_domain(&host))
201 }
202
203 pub fn cookie_url(&self, text: &str) -> bool {
204 let Some(url) = self.unwrap(parse(text)) else {
205 return true;
206 };
207 let host = host_of(&url);
208 if host.is_empty() || self.url(text) {
209 return true;
210 }
211 if !matches!(url.host(), Some(url::Host::Domain(_))) {
212 return self.cookie_domain(&host);
213 }
214 let labels: Vec<&str> = host.split('.').collect();
215 (0..labels.len()).any(|at| self.cookie_scope(&labels[at..].join(".")))
216 }
217
218 pub fn storage_key(&self, key: &str) -> bool {
219 let mut parts = key.split('^');
220 let first = parts.next().unwrap_or("");
221 if self.url_read_only(first) {
222 return true;
223 }
224 parts
225 .filter_map(|part| part.strip_prefix('0'))
226 .any(|site| self.url_read_only(site))
227 }
228
229 pub fn pattern(&self, pattern: &str) -> bool {
230 let lowered = pattern.to_ascii_lowercase();
231 if self
232 .rules
233 .any_schemes
234 .iter()
235 .any(|scheme| lowered.starts_with(&format!("{scheme}:")))
236 {
237 return true;
238 }
239 let names: Vec<&String> = self
240 .rules
241 .domains
242 .iter()
243 .chain(self.rules.hosts.iter())
244 .chain(self.extra_hosts.iter())
245 .collect();
246 if pattern_hosts(&lowered)
247 .iter()
248 .any(|host| names.iter().any(|name| host_reaches(host, name)))
249 {
250 return true;
251 }
252 let literal: String = pattern
253 .chars()
254 .take_while(|ch| !matches!(ch, '*' | '?' | '{' | '}' | '[' | ']' | '\\'))
255 .collect();
256 literal.contains("://") && self.url_read_only(&literal)
257 }
258
259 pub(crate) fn inheriting(&self, text: &str) -> bool {
260 if text.is_empty() {
261 return true;
262 }
263 let Some(url) = parse(text) else {
264 return false;
265 };
266 url.scheme() == "about"
267 && self
268 .rules
269 .inheriting
270 .iter()
271 .any(|item| *item == format!("about:{}", url.path().to_ascii_lowercase()))
272 }
273
274 fn rewrite_about(&self, url: Url) -> Url {
275 if url.scheme() != "about"
276 || self.rules.about_scheme.is_empty()
277 || self.inheriting(url.as_str())
278 {
279 return url;
280 }
281 let rest = &url.as_str()[url.scheme().len() + 1..];
282 parse(&format!("{}://{rest}", self.rules.about_scheme)).unwrap_or(url)
283 }
284
285 fn unwrap(&self, mut url: Option<Url>) -> Option<Url> {
286 let mut depth = 0;
287 while let Some(current) = url.as_ref() {
288 if !self
289 .rules
290 .inner_schemes
291 .iter()
292 .any(|item| item == current.scheme())
293 {
294 break;
295 }
296 if depth >= 8 {
297 return None;
298 }
299 let rest = current.as_str()[current.scheme().len() + 1..].to_string();
300 url = parse(&rest);
301 depth += 1;
302 }
303 url
304 }
305}
306
307fn pattern_hosts(pattern: &str) -> Vec<String> {
308 if let Some(index) = pattern.find("://") {
309 let scheme = &pattern[..index];
310 if !scheme.is_empty()
311 && scheme.chars().all(|ch| {
312 ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.' | '*' | '{' | '}' | ',')
313 })
314 {
315 let rest = &pattern[index + 3..];
316 let end = rest.find(['/', '#', '?']).unwrap_or(rest.len());
317 return authority_host(&rest[..end]).into_iter().collect();
318 }
319 }
320 let end = pattern.find(['#', '?']).unwrap_or(pattern.len());
321 pattern[..end]
322 .split('/')
323 .filter_map(authority_host)
324 .filter(|host| host.contains('.'))
325 .collect()
326}
327
328fn authority_host(authority: &str) -> Option<String> {
329 let host = authority.rsplit('@').next().unwrap_or(authority);
330 let host = match host.rfind(':') {
331 Some(index)
332 if !host.starts_with('[')
333 && host[index + 1..]
334 .chars()
335 .all(|ch| ch.is_ascii_digit() || ch == '*') =>
336 {
337 &host[..index]
338 }
339 _ => host,
340 };
341 let host = host.strip_suffix('.').unwrap_or(host);
342 if host.is_empty()
343 || host
344 .chars()
345 .all(|ch| matches!(ch, '*' | '?' | '{' | '}' | ','))
346 {
347 return None;
348 }
349 Some(host.to_string())
350}
351
352fn host_reaches(glob: &str, name: &str) -> bool {
353 let first: String = glob
354 .split('.')
355 .next()
356 .unwrap_or("")
357 .chars()
358 .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
359 .collect();
360 let label = if first.is_empty() {
361 "x".to_string()
362 } else {
363 first
364 };
365 [
366 name.to_string(),
367 format!("x.{name}"),
368 format!("{label}.{name}"),
369 ]
370 .iter()
371 .any(|candidate| host_glob(glob.as_bytes(), candidate.as_bytes()))
372}
373
374fn host_glob(glob: &[u8], text: &[u8]) -> bool {
375 let Some((&first, rest)) = glob.split_first() else {
376 return text.is_empty();
377 };
378 match first {
379 b'*' => (0..=text.len()).any(|taken| host_glob(rest, &text[taken..])),
380 b'{' => match rest.iter().position(|ch| *ch == b'}') {
381 Some(close) => {
382 (0..=text.len()).any(|taken| host_glob(&rest[close + 1..], &text[taken..]))
383 }
384 None => text.first() == Some(&b'{') && host_glob(rest, &text[1..]),
385 },
386 b'?' => !text.is_empty() && host_glob(rest, &text[1..]),
387 b'\\' => match rest.split_first() {
388 Some((&literal, after)) => {
389 text.first() == Some(&literal) && host_glob(after, &text[1..])
390 }
391 None => text.is_empty(),
392 },
393 literal => text.first() == Some(&literal) && host_glob(rest, &text[1..]),
394 }
395}
396
397pub fn canonical_cookie_domain(domain: &str) -> Option<String> {
398 if domain.is_empty() || domain.chars().any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\' | '@' | '?' | '#')) {
399 return None;
400 }
401 let normalized = domain.replace(['。', '.', '。'], ".");
402 let normalized = normalized.trim_start_matches('.');
403 let url = parse(&format!("http://{normalized}/"))?;
404 let host = host_of(&url).trim_matches('.').to_string();
405 if matches!(url.host(), Some(url::Host::Domain(_))) && host.split('.').any(|label| {
406 label.is_empty() || label.starts_with('-') || label.ends_with('-')
407 || !label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
408 }) {
409 return None;
410 }
411 Some(host)
412}
413
414fn parse(text: &str) -> Option<Url> {
415 Url::parse(text).ok()
416}
417
418fn host_of(url: &Url) -> String {
419 let host = url.host_str().unwrap_or("").to_ascii_lowercase();
420 match host.strip_suffix('.') {
421 Some(stripped) => stripped.to_string(),
422 None => host,
423 }
424}
425
426fn origin_of(url: &Url) -> Option<String> {
427 url.host_str().filter(|host| !host.is_empty())?;
428 let host = host_of(url);
429 Some(match url.port() {
430 Some(port) => format!("{}://{host}:{port}", url.scheme()),
431 None => format!("{}://{host}", url.scheme()),
432 })
433}
434
435fn under(host: &str, domain: &str) -> bool {
436 host == domain || host.ends_with(&format!(".{domain}"))
437}
438
439#[cfg(test)]
440mod tests {
441 use super::{Opener, Protected};
442 use slicc_browsers::protected::{
443 vectors, OPENER_PROTECTED, OPENER_UNKNOWN, OPENER_UNPROTECTED,
444 };
445
446 fn guard() -> Protected {
447 let vectors = vectors();
448 let origins: Vec<&str> = vectors.origins.iter().map(String::as_str).collect();
449 Protected::new(&origins)
450 }
451
452 #[test]
453 fn unparseable_policy_and_refusal_are_catalog_driven() {
454 let mut rules = slicc_browsers::catalog().protected_origins;
455 rules.unparseable_refusal = "custom unparseable refusal".into();
456 let guard = Protected::with_rules(rules.clone(), &[]);
457 for text in ["https://exa%20mple.example/", "blob:invalid", "view-source:invalid"] {
458 assert!(guard.unparseable(text));
459 assert!(guard.url(text));
460 assert!(!guard.url_read_only(text));
461 assert!(!guard.storage_key(text));
462 assert_eq!(guard.refusal_for_url(text), "custom unparseable refusal");
463 }
464 for text in ["", "null", "://"] {
465 assert!(!guard.unparseable(text));
466 assert!(!guard.url(text));
467 }
468 let at_limit = format!("{}https://example.test/", "view-source:".repeat(8));
469 assert!(!guard.unparseable(&at_limit));
470 assert!(!guard.url(&at_limit));
471 let over_limit = format!("view-source:{at_limit}");
472 assert!(guard.unparseable(&over_limit));
473 assert!(guard.url(&over_limit));
474 rules.unparseable = "not protected".into();
475 let guard = Protected::with_rules(rules, &[]);
476 assert!(!guard.url("https://exa%20mple.example/"));
477 assert_eq!(guard.refusal_for_url("https://exa%20mple.example/"), guard.refusal());
478 for url in ["http://xn--1ug.sliccy.ai/", "http://xn--a.sliccy.ai/"] {
479 let guard = Protected::default();
480 assert!(guard.url(url));
481 assert!(guard.unparseable(url));
482 }
483 }
484
485 #[test]
486 fn privileged_rules_are_catalog_driven() {
487 let mut rules = slicc_browsers::catalog().protected_origins;
488 rules.protected_schemes = vec!["privileged".into()];
489 rules.scheme_exceptions = vec!["privileged://newtab".into()];
490 rules.about_scheme = "privileged".into();
491 rules.target_types = vec!["custom_ui".into()];
492 let guard = Protected::with_rules(rules.clone(), &[]);
493 for url in ["privileged://settings", "about:settings", "view-source:about:settings", "blob:privileged://settings/id", "filesystem:privileged://settings/temporary/x"] {
494 assert!(guard.url(url), "{url}");
495 }
496 for url in ["privileged://newtab/path", "about:newtab/path", "about:blank", "about:srcdoc", "chrome://settings"] {
497 assert!(!guard.url(url), "{url}");
498 }
499 assert!(guard.target_type("custom_ui"));
500 assert!(!guard.target_type("browser_ui"));
501 rules.about_scheme.clear();
502 assert!(!Protected::with_rules(rules, &[]).url("about:settings"));
503 }
504
505 #[test]
506 fn every_url_vector_matches_slicc_browsers() {
507 let guard = guard();
508 for vector in vectors().urls {
509 assert_eq!(guard.url(&vector.url), vector.protected, "{:?}", vector.url);
510 }
511 }
512
513 #[test]
514 fn every_target_vector_matches_slicc_browsers() {
515 let guard = guard();
516 for vector in vectors().targets {
517 let opener = match vector.opener.as_deref() {
518 None => Opener::None,
519 Some(OPENER_PROTECTED) => Opener::Protected,
520 Some(OPENER_UNPROTECTED) => Opener::Unprotected,
521 Some(OPENER_UNKNOWN) => Opener::Unknown,
522 Some(other) => panic!("unknown opener {other}"),
523 };
524 assert_eq!(
525 guard.target_type(vector.kind.as_deref().unwrap_or(""))
526 || guard.target_with_document(&vector.url, vector.previous, opener, vector.document_url.as_deref(), vector.unreachable_url.as_deref(), vector.navigating),
527 vector.protected,
528 "{vector:?}"
529 );
530 }
531 }
532
533 #[test]
534 fn every_cookie_and_storage_vector_matches_slicc_browsers() {
535 let guard = guard();
536 let all = vectors();
537 for vector in all.cookie_domains {
538 assert_eq!(
539 guard.cookie_domain(&vector.domain),
540 vector.protected,
541 "{vector:?}"
542 );
543 }
544 for vector in all.storage_keys {
545 assert_eq!(
546 guard.storage_key(&vector.key),
547 vector.protected,
548 "{vector:?}"
549 );
550 }
551 }
552
553 #[test]
554 fn patterns_that_name_seven_or_the_vault_are_protected() {
555 let guard = Protected::default();
556 for pattern in [
557 "https://vault.sliccy.ai/*",
558 "**://*.sliccy.ai/**",
559 "*sliccy.ai*",
560 "https://SEVEN.SLICCY.AI/api/*",
561 "https://www.sliccy.ai/auth/callback*",
562 "devtools://*",
563 "https://sliccy.ai/",
564 ] {
565 assert!(guard.pattern(pattern), "{pattern}");
566 }
567 for pattern in [
568 "**://*.sliccy.ai/**",
569 "**/*.sliccy.ai/**",
570 "https://seven.sliccy.*/x",
571 "https://user@vault.sliccy.ai:443/x",
572 "https://vault.sliccy.ai./x",
573 "*://{seven,other}.sliccy.ai/*",
574 ] {
575 assert!(guard.pattern(pattern), "{pattern}");
576 }
577 for pattern in [
578 "*",
579 "**/*",
580 "**/api/users",
581 "https://*/api",
582 "https://example.com/*",
583 "https://vault-sliccy.example/*",
584 "https://notsliccy.ai/*",
585 "https://vault.sliccy.ai.example/*",
586 "https://vault-sliccy.ai/*",
587 "**/x?next=https://seven.sliccy.ai/",
588 "https://example.com/?next=https://seven.sliccy.ai/",
589 "*/blocked*",
590 "**/api/*/details",
591 ] {
592 assert!(!guard.pattern(pattern), "{pattern}");
593 }
594 assert_eq!(
595 guard.refusal(),
596 "slicc: refused: target is on seven's origin"
597 );
598 }
599}