use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
use url::Url;
pub const MAX_DISCOVERED_SUBRESOURCES: usize = 512;
pub const DEFAULT_MAX_BLOCKED_HOSTS: usize = 2_048;
pub const DEFAULT_MAX_SITE_ALLOWLIST: usize = 512;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SearchProvider {
#[default]
Google,
#[serde(rename = "duckduckgo")]
DuckDuckGo,
Brave,
Startpage,
}
impl SearchProvider {
pub const ALL: [Self; 4] = [Self::Google, Self::DuckDuckGo, Self::Brave, Self::Startpage];
pub fn id(&self) -> &'static str {
match self {
Self::Google => "google",
Self::DuckDuckGo => "duckduckgo",
Self::Brave => "brave",
Self::Startpage => "startpage",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Google => "Google",
Self::DuckDuckGo => "DuckDuckGo",
Self::Brave => "Brave Search",
Self::Startpage => "Startpage",
}
}
pub fn search_action(&self) -> &'static str {
match self {
Self::Google => "https://www.google.com/search",
Self::DuckDuckGo => "https://duckduckgo.com/",
Self::Brave => "https://search.brave.com/search",
Self::Startpage => "https://www.startpage.com/sp/search",
}
}
pub fn query_parameter(&self) -> &'static str {
match self {
Self::Startpage => "query",
_ => "q",
}
}
pub fn search_url(&self, query: &str) -> anyhow::Result<Url> {
let mut url = Url::parse(self.search_action())?;
url.query_pairs_mut()
.append_pair(self.query_parameter(), query);
Ok(url)
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|provider| provider.id() == id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shields {
pub enabled: bool,
pub https_upgrade: bool,
pub global_privacy_control: bool,
pub do_not_track: bool,
pub strip_tracking_queries: bool,
pub debounce_redirects: bool,
pub block_trackers: bool,
pub de_amp: bool,
pub blocked_hosts: BTreeSet<String>,
pub site_allowlist: BTreeSet<String>,
}
impl Default for Shields {
fn default() -> Self {
Self {
enabled: true,
https_upgrade: true,
global_privacy_control: true,
do_not_track: true,
strip_tracking_queries: true,
debounce_redirects: true,
block_trackers: true,
de_amp: true,
blocked_hosts: built_in_blocked_hosts(),
site_allowlist: BTreeSet::new(),
}
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct PrivacyReport {
pub original_url: String,
pub final_url: String,
pub upgraded_to_https: bool,
pub stripped_query_params: Vec<String>,
pub debounced_redirect: Option<String>,
pub blocked_requests: Vec<String>,
pub de_amped_to: Option<String>,
}
impl PrivacyReport {
pub fn has_actions(&self) -> bool {
self.upgraded_to_https
|| !self.stripped_query_params.is_empty()
|| self.debounced_redirect.is_some()
|| !self.blocked_requests.is_empty()
|| self.de_amped_to.is_some()
}
}
impl Shields {
pub fn is_enabled_for(&self, url: &Url) -> bool {
self.enabled
&& is_web_url(url)
&& url
.host_str()
.map(|host| !self.site_allowlist.contains(&canonical_host(host)))
.unwrap_or(false)
}
pub fn prepare_navigation(&self, input: &str) -> anyhow::Result<(Url, PrivacyReport)> {
self.prepare_navigation_with_search(input, SearchProvider::default())
}
pub fn prepare_navigation_with_search(
&self,
input: &str,
search_provider: SearchProvider,
) -> anyhow::Result<(Url, PrivacyReport)> {
let mut url = parse_navigation_input_with_search(input, search_provider)?;
let mut report = PrivacyReport {
original_url: input.to_string(),
final_url: url.to_string(),
..PrivacyReport::default()
};
if !self.is_enabled_for(&url) {
report.final_url = url.to_string();
return Ok((url, report));
}
if self.debounce_redirects
&& let Some(destination) = debounced_destination(&url)
{
report.debounced_redirect = Some(destination.to_string());
url = destination;
}
if self.https_upgrade && url.scheme() == "http" && !is_local_host(&url) {
let _ = url.set_scheme("https");
report.upgraded_to_https = true;
}
if self.strip_tracking_queries {
let removed = strip_tracking_params(&mut url);
report.stripped_query_params = removed;
}
report.final_url = url.to_string();
Ok((url, report))
}
pub fn clean_share_url(&self, input: &str) -> anyhow::Result<Url> {
let (mut url, _) = self.prepare_navigation(input)?;
if self.strip_tracking_queries && self.is_enabled_for(&url) {
strip_share_tracking_params(&mut url);
}
Ok(url)
}
pub fn request_headers(&self, url: &Url) -> Vec<(&'static str, &'static str)> {
if !self.is_enabled_for(url) {
return Vec::new();
}
let mut headers = vec![
(
"Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
),
("Accept-Language", "en-US,en;q=0.9"),
];
headers.extend(self.privacy_headers(url));
headers
}
pub fn privacy_headers(&self, url: &Url) -> Vec<(&'static str, &'static str)> {
if !self.is_enabled_for(url) {
return Vec::new();
}
let mut headers = Vec::new();
if self.https_upgrade {
headers.push(("Upgrade-Insecure-Requests", "1"));
}
if self.global_privacy_control {
headers.push(("Sec-GPC", "1"));
}
if self.do_not_track {
headers.push(("DNT", "1"));
}
headers
}
pub fn should_block_request(&self, page_url: &Url, request_url: &Url) -> bool {
if !self.block_trackers || !self.is_enabled_for(page_url) || !is_web_url(request_url) {
return false;
}
let Some(host) = request_url.host_str().map(canonical_host) else {
return false;
};
self.blocked_hosts
.iter()
.any(|blocked| blocked_host_matches(&host, blocked))
}
pub fn allow_site(&mut self, url: &Url) {
if is_web_url(url)
&& let Some(host) = url.host_str()
{
self.site_allowlist.insert(canonical_host(host));
self.enforce_limits();
}
}
pub fn protect_site(&mut self, url: &Url) {
if is_web_url(url)
&& let Some(host) = url.host_str()
{
self.site_allowlist.remove(&canonical_host(host));
}
}
pub fn enforce_limits(&mut self) {
normalize_blocked_hosts(&mut self.blocked_hosts);
normalize_host_set(&mut self.site_allowlist, DEFAULT_MAX_SITE_ALLOWLIST);
}
}
fn normalize_blocked_hosts(values: &mut BTreeSet<String>) {
let required = built_in_blocked_hosts();
let custom_max = DEFAULT_MAX_BLOCKED_HOSTS.saturating_sub(required.len());
let mut normalized = values
.iter()
.filter_map(|host| normalize_host_rule(host))
.filter(|host| !required.contains(host))
.collect::<BTreeSet<_>>();
prune_btree_set(&mut normalized, custom_max);
normalized.extend(required);
*values = normalized;
}
fn normalize_host_set(values: &mut BTreeSet<String>, max_values: usize) {
*values = values
.iter()
.filter_map(|host| normalize_host_rule(host))
.collect();
prune_btree_set(values, max_values);
}
fn prune_btree_set(values: &mut BTreeSet<String>, max_values: usize) {
let max_values = max_values.max(1);
while values.len() > max_values {
let Some(value) = values.first().cloned() else {
break;
};
values.remove(&value);
}
}
pub fn parse_navigation_input(input: &str) -> anyhow::Result<Url> {
parse_navigation_input_with_search(input, SearchProvider::default())
}
pub fn parse_navigation_input_with_search(
input: &str,
search_provider: SearchProvider,
) -> anyhow::Result<Url> {
let trimmed = input.trim();
if trimmed.is_empty() {
anyhow::bail!("empty navigation input");
}
if let Ok(url) = Url::parse(trimmed) {
if matches!(url.scheme(), "http" | "https" | "cephas") || url.as_str() == "about:blank" {
return Ok(url);
}
anyhow::bail!("unsupported URL scheme: {}", url.scheme());
}
if looks_like_domain(trimmed) {
return Ok(Url::parse(&format!("https://{trimmed}"))?);
}
search_provider.search_url(trimmed)
}
pub fn strip_tracking_params(url: &mut Url) -> Vec<String> {
let Some(query) = url.query().map(ToString::to_string) else {
return Vec::new();
};
let original_pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
let mut removed = Vec::new();
let tracking_names = tracking_param_names();
let kept: Vec<(String, String)> = original_pairs
.into_iter()
.filter_map(|(key, value)| {
let lower = key.to_ascii_lowercase();
let is_tracker = lower.starts_with("utm_")
|| tracking_names.contains(lower.as_str())
|| lower.starts_with("vero_")
|| lower.starts_with("oly_");
if is_tracker {
removed.push(key);
None
} else {
Some((key, value))
}
})
.collect();
if removed.is_empty() {
return removed;
}
url.set_query(None);
if !kept.is_empty() {
let mut pairs = url.query_pairs_mut();
for (key, value) in kept {
pairs.append_pair(&key, &value);
}
}
if query.is_empty() {
url.set_query(Some(""));
}
removed.sort();
removed.dedup();
removed
}
pub fn strip_share_tracking_params(url: &mut Url) -> Vec<String> {
strip_share_tracking_params_inner(url, 0)
}
fn strip_share_tracking_params_inner(url: &mut Url, depth: usize) -> Vec<String> {
let Some(query) = url.query().map(ToString::to_string) else {
return Vec::new();
};
let host = url.host_str().map(canonical_host);
let original_pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
let mut removed = Vec::new();
let mut kept = Vec::new();
for (key, value) in original_pairs {
let lower = key.to_ascii_lowercase();
if is_share_tracking_param(host.as_deref(), &lower) {
removed.push(key);
continue;
}
let (value, nested_removed) = strip_nested_share_url_value(&key, value, depth);
removed.extend(nested_removed);
kept.push((key, value));
}
if removed.is_empty() {
return removed;
}
url.set_query(None);
if !kept.is_empty() {
let mut pairs = url.query_pairs_mut();
for (key, value) in kept {
pairs.append_pair(&key, &value);
}
}
if query.is_empty() {
url.set_query(Some(""));
}
removed.sort();
removed.dedup();
removed
}
fn strip_nested_share_url_value(key: &str, value: String, depth: usize) -> (String, Vec<String>) {
if depth >= 3 {
return (value, Vec::new());
}
let Ok(mut nested) = Url::parse(&value) else {
return (value, Vec::new());
};
if !matches!(nested.scheme(), "http" | "https") {
return (value, Vec::new());
}
let removed = strip_share_tracking_params_inner(&mut nested, depth + 1)
.into_iter()
.map(|param| format!("{key}.{param}"))
.collect::<Vec<_>>();
if removed.is_empty() {
(value, removed)
} else {
(nested.to_string(), removed)
}
}
pub fn debounced_destination(url: &Url) -> Option<Url> {
let host = url.host_str()?.to_ascii_lowercase();
let redirector_hosts = [
"t.co",
"l.facebook.com",
"lm.facebook.com",
"out.reddit.com",
"www.google.com",
"google.com",
"duckduckgo.com",
"l.instagram.com",
"href.li",
"link.zhihu.com",
];
if !redirector_hosts
.iter()
.any(|candidate| host == *candidate || host.ends_with(&format!(".{candidate}")))
{
return None;
}
let keys = [
"url",
"u",
"target",
"to",
"dest",
"destination",
"redirect",
"redirect_url",
];
for (key, value) in url.query_pairs() {
if keys
.iter()
.any(|candidate| key.eq_ignore_ascii_case(candidate))
&& let Ok(destination) = Url::parse(&value)
&& (destination.scheme() == "http" || destination.scheme() == "https")
{
return Some(destination);
}
}
None
}
pub fn discover_subresources(html: &str, base: &Url) -> Vec<Url> {
let re = Regex::new(r#"(?is)(?:src|href)\s*=\s*["']([^"'#\s][^"']*)["']"#)
.expect("valid subresource regex");
let mut seen = HashSet::new();
let mut urls = Vec::new();
for captures in re.captures_iter(html) {
if urls.len() >= MAX_DISCOVERED_SUBRESOURCES {
break;
}
let Some(raw) = captures
.get(1)
.map(|m| html_escape::decode_html_entities(m.as_str()))
else {
continue;
};
if raw.starts_with("data:")
|| raw.starts_with("blob:")
|| raw.starts_with("mailto:")
|| raw.starts_with("tel:")
|| raw.starts_with("javascript:")
{
continue;
}
if let Ok(url) = base.join(&raw)
&& seen.insert(url.to_string())
{
urls.push(url);
}
}
urls
}
fn looks_like_domain(input: &str) -> bool {
let without_path = input.split('/').next().unwrap_or(input);
without_path.contains('.') && !without_path.contains(' ') && !without_path.contains('\t')
}
fn is_local_host(url: &Url) -> bool {
matches!(
url.host_str(),
Some("localhost") | Some("127.0.0.1") | Some("::1")
)
}
fn is_web_url(url: &Url) -> bool {
matches!(url.scheme(), "http" | "https")
}
fn canonical_host(host: &str) -> String {
host.trim_end_matches('.').to_ascii_lowercase()
}
fn normalize_host_rule(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(url) = Url::parse(trimmed) {
return url.host_str().map(canonical_host);
}
let without_path = trimmed
.split(['/', '?', '#'])
.next()
.unwrap_or(trimmed)
.trim();
let without_wildcard = without_path
.strip_prefix("*.")
.unwrap_or(without_path)
.trim_start_matches('.');
if without_wildcard.is_empty() || without_wildcard.chars().any(char::is_whitespace) {
return None;
}
Url::parse(&format!("https://{without_wildcard}"))
.ok()
.and_then(|url| url.host_str().map(canonical_host))
}
fn blocked_host_matches(host: &str, rule: &str) -> bool {
let Some(rule) = normalize_host_rule(rule) else {
return false;
};
host == rule
|| host
.strip_suffix(&rule)
.is_some_and(|prefix| prefix.ends_with('.'))
}
fn tracking_param_names() -> HashSet<&'static str> {
HashSet::from([
"fbclid",
"gclid",
"gclsrc",
"dclid",
"gbraid",
"wbraid",
"msclkid",
"mc_cid",
"mc_eid",
"igshid",
"yclid",
"_hsenc",
"_hsmi",
"mkt_tok",
"vero_id",
"spm",
"sc_campaign",
"sc_channel",
"sc_content",
"sc_medium",
"sc_outcome",
"sc_geo",
"pk_campaign",
"pk_kwd",
"piwik_campaign",
"piwik_kwd",
"wickedid",
"twclid",
"li_fat_id",
"trk",
"trkemail",
"zanpid",
"ref_src",
"ref_url",
"ncid",
"soc_src",
"soc_trk",
])
}
fn is_share_tracking_param(host: Option<&str>, lower: &str) -> bool {
lower.starts_with("utm_")
|| lower.starts_with("hsa_")
|| lower.starts_with("vero_")
|| lower.starts_with("oly_")
|| share_global_tracking_param_names().contains(lower)
|| host
.map(|host| share_site_tracking_param_names(host).contains(&lower))
.unwrap_or(false)
}
fn share_global_tracking_param_names() -> HashSet<&'static str> {
HashSet::from([
"_gl",
"li_fat_id",
"ymid",
"var",
"s_cid",
"wt_mc",
"fbclid",
"gclid",
"gclsrc",
"dclid",
"gbraid",
"wbraid",
"msclkid",
"mc_cid",
"mc_eid",
"igshid",
"yclid",
"_hsenc",
"_hsmi",
"mkt_tok",
"spm",
"twclid",
])
}
fn share_site_tracking_param_names(host: &str) -> &'static [&'static str] {
match host {
"twitter.com" | "www.twitter.com" | "x.com" => &["ref_src", "ref_url"],
"www.instagram.com" => &["igshid", "ig_rid"],
host if AMAZON_SHARE_HOSTS.contains(&host) => &[
"__mk_de_de",
"aaxitk",
"content-id",
"creativeid",
"crid",
"dib",
"dib_tag",
"hsa_cr_id",
"ipredirectfrom",
"ipredirectoriginalurl",
"keywords",
"lp_asins",
"lp_query",
"lp_slot",
"overridebasecountry",
"pageloadid",
"pd_rd_plhdr",
"pd_rd_r",
"pd_rd_w",
"pd_rd_wg",
"pf_rd_r",
"pf_rd_p",
"plink",
"psc",
"ref",
"ref_",
"ref_pageloadid",
"sbo",
"social_share",
"source_code",
"sprefix",
"s_prefix",
"sp_csd",
"sr",
"store_ref",
],
"www.handelsblatt.com" => &["share"],
host if SHOPEE_SHARE_HOSTS.contains(&host) => &["sp_atk", "xptdk"],
"www.tokopedia.com" => &["extparam"],
"open.spotify.com" => &["si"],
"www.meetup.com" => &["eventorigin", "recid", "recsource", "searchid"],
"www.facebook.com" => &["__cft__[0]", "__tn__"],
host if WIKIMEDIA_SHARE_HOSTS.contains(&host) => &["wprov"],
"www.bilibili.com" | "space.bilibili.com" | "live.bilibili.com" => {
&["spm_id_from", "broadcast_type", "is_room_feed", "live_from"]
}
"www.humblebundle.com" => &["hmb_source", "hmb_medium", "hmb_campaign"],
_ => &[],
}
}
const AMAZON_SHARE_HOSTS: &[&str] = &[
"www.amazon.com",
"www.amazon.de",
"www.amazon.nl",
"www.amazon.fr",
"www.amazon.co.jp",
"www.amazon.in",
"www.amazon.es",
"www.amazon.ac",
"www.amazon.cn",
"www.amazon.eg",
"www.amazon.co.uk",
"www.amazon.it",
"www.amazon.pl",
"www.amazon.sg",
"www.amazon.ca",
"www.amazon.com.au",
"www.amazon.com.br",
"www.amazon.com.mx",
"www.amazon.com.be",
"www.amazon.com.tr",
"www.amazon.sa",
"www.amazon.ae",
"www.audible.com",
"www.audible.co.uk",
"www.audible.de",
"www.audible.fr",
"www.audible.it",
];
const SHOPEE_SHARE_HOSTS: &[&str] = &[
"shopee.co.id",
"shopee.tw",
"shopee.vn",
"shopee.co.vn",
"shopee.ph",
"shopee.com.my",
"shopee.sg",
"shopee.com.br",
"shopee.com.mx",
"shopee.com.co",
"shopee.cl",
];
const WIKIMEDIA_SHARE_HOSTS: &[&str] = &[
"en.wikipedia.org",
"ja.wikipedia.org",
"de.wikipedia.org",
"ru.wikipedia.org",
"es.wikipedia.org",
"fr.wikipedia.org",
"zh.wikipedia.org",
"it.wikipedia.org",
"fa.wikipedia.org",
"pt.wikipedia.org",
"www.mediawiki.org",
"w.wiki",
"www.wikidata.org",
"test.wikidata.org",
"www.wikifunctions.org",
"meta.wikimedia.org",
"commons.wikimedia.org",
];
fn built_in_blocked_hosts() -> BTreeSet<String> {
[
"doubleclick.net",
"googleadservices.com",
"googlesyndication.com",
"googletagmanager.com",
"googletagservices.com",
"google-analytics.com",
"analytics.google.com",
"facebook.net",
"connect.facebook.net",
"ads.twitter.com",
"analytics.twitter.com",
"scorecardresearch.com",
"quantserve.com",
"taboola.com",
"outbrain.com",
"criteo.com",
"adsrvr.org",
"adnxs.com",
"rubiconproject.com",
"pubmatic.com",
"moatads.com",
"hotjar.com",
"segment.io",
"mixpanel.com",
"amplitude.com",
"cdn.optimizely.com",
"newrelic.com",
"nr-data.net",
]
.into_iter()
.map(String::from)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navigation_upgrades_and_strips_tracking() {
let shields = Shields::default();
let (url, report) = shields
.prepare_navigation("http://example.com/?utm_source=x&a=1&fbclid=abc")
.unwrap();
assert_eq!(url.as_str(), "https://example.com/?a=1");
assert!(report.upgraded_to_https);
assert_eq!(report.stripped_query_params, vec!["fbclid", "utm_source"]);
}
#[test]
fn navigation_debounces_redirectors() {
let shields = Shields::default();
let (url, report) = shields
.prepare_navigation("https://l.facebook.com/l.php?u=https%3A%2F%2Fexample.com%2Fstory%3Fgclid%3D1%26id%3D7")
.unwrap();
assert_eq!(url.as_str(), "https://example.com/story?id=7");
assert_eq!(
report.debounced_redirect.as_deref(),
Some("https://example.com/story?gclid=1&id=7")
);
}
#[test]
fn clean_share_url_uses_firefox_global_strip_on_share_params() {
let shields = Shields::default();
let url = shields
.clean_share_url("https://example.com/?_gl=1&utm_source=x&id=7")
.unwrap();
assert_eq!(url.as_str(), "https://example.com/?id=7");
}
#[test]
fn clean_share_url_uses_site_specific_share_rules_without_changing_navigation() {
let shields = Shields::default();
let input = "https://www.amazon.com/dp/example?ref=share&psc=1&tag=keep&id=7";
let (navigation, report) = shields.prepare_navigation(input).unwrap();
let clean = shields.clean_share_url(input).unwrap();
assert_eq!(navigation.as_str(), input);
assert!(report.stripped_query_params.is_empty());
assert_eq!(
clean.as_str(),
"https://www.amazon.com/dp/example?tag=keep&id=7"
);
}
#[test]
fn clean_share_url_strips_nested_url_values() {
let shields = Shields::default();
let clean = shields
.clean_share_url("https://example.com/share?target=https%3A%2F%2Fdest.example%2F%3F_gl%3D1%26id%3D7&utm_source=x")
.unwrap();
assert_eq!(
clean.as_str(),
"https://example.com/share?target=https%3A%2F%2Fdest.example%2F%3Fid%3D7"
);
}
#[test]
fn search_provider_ids_are_unique_and_resolvable() {
let mut ids = BTreeSet::new();
for provider in SearchProvider::ALL {
assert!(ids.insert(provider.id()));
assert_eq!(SearchProvider::from_id(provider.id()), Some(provider));
assert_eq!(
serde_json::to_value(provider).unwrap().as_str(),
Some(provider.id())
);
assert_eq!(
serde_json::from_value::<SearchProvider>(serde_json::json!(provider.id())).unwrap(),
provider
);
assert!(!provider.label().trim().is_empty());
}
}
#[test]
fn search_providers_build_provider_specific_urls() {
assert_eq!(
parse_navigation_input_with_search("rust webkit", SearchProvider::Google)
.unwrap()
.as_str(),
"https://www.google.com/search?q=rust+webkit"
);
assert_eq!(
parse_navigation_input_with_search("rust webkit", SearchProvider::DuckDuckGo)
.unwrap()
.as_str(),
"https://duckduckgo.com/?q=rust+webkit"
);
assert_eq!(
parse_navigation_input_with_search("rust webkit", SearchProvider::Brave)
.unwrap()
.as_str(),
"https://search.brave.com/search?q=rust+webkit"
);
assert_eq!(
parse_navigation_input_with_search("rust webkit", SearchProvider::Startpage)
.unwrap()
.as_str(),
"https://www.startpage.com/sp/search?query=rust+webkit"
);
}
#[test]
fn blocks_tracker_subdomains() {
let shields = Shields::default();
let page = Url::parse("https://news.example/").unwrap();
let tracker = Url::parse("https://stats.google-analytics.com/script.js").unwrap();
assert!(shields.should_block_request(&page, &tracker));
}
#[test]
fn armour_host_rules_normalize_and_match_only_host_boundaries() {
let mut shields = Shields {
blocked_hosts: BTreeSet::from([
" HTTPS://TRACKER.EXAMPLE./pixel ".to_string(),
"*.Ads.Example".to_string(),
"bad host".to_string(),
]),
..Shields::default()
};
shields.enforce_limits();
assert!(shields.blocked_hosts.contains("tracker.example"));
assert!(shields.blocked_hosts.contains("ads.example"));
assert!(!shields.blocked_hosts.contains("bad host"));
let page = Url::parse("https://news.example/").unwrap();
assert!(shields.should_block_request(
&page,
&Url::parse("https://cdn.tracker.example/pixel.js").unwrap()
));
assert!(shields.should_block_request(
&page,
&Url::parse("https://a.ads.example/script.js").unwrap()
));
assert!(!shields.should_block_request(
&page,
&Url::parse("https://nottracker.example/pixel.js").unwrap()
));
}
#[test]
fn armour_is_scoped_to_web_origins_and_site_allowlist() {
let mut shields = Shields::default();
let web = Url::parse("https://Example.COM./article").unwrap();
let internal = Url::parse("cephas://settings").unwrap();
let tracker = Url::parse("https://stats.google-analytics.com/script.js").unwrap();
assert!(shields.is_enabled_for(&web));
assert!(!shields.is_enabled_for(&internal));
assert!(shields.should_block_request(&web, &tracker));
shields.allow_site(&internal);
assert!(shields.site_allowlist.is_empty());
shields.allow_site(&web);
assert!(shields.site_allowlist.contains("example.com"));
assert!(!shields.is_enabled_for(&web));
assert!(!shields.should_block_request(&web, &tracker));
shields.protect_site(&web);
assert!(shields.is_enabled_for(&web));
}
#[test]
fn armour_request_headers_follow_feature_toggles() {
let url = Url::parse("https://example.com/").unwrap();
let mut shields = Shields::default();
let headers = shields.request_headers(&url);
assert!(headers.contains(&("Upgrade-Insecure-Requests", "1")));
assert!(headers.contains(&("Sec-GPC", "1")));
assert!(headers.contains(&("DNT", "1")));
shields.https_upgrade = false;
shields.global_privacy_control = false;
shields.do_not_track = false;
let headers = shields.request_headers(&url);
assert!(!headers.contains(&("Upgrade-Insecure-Requests", "1")));
assert!(!headers.contains(&("Sec-GPC", "1")));
assert!(!headers.contains(&("DNT", "1")));
assert!(headers.iter().any(|(name, _)| *name == "Accept"));
shields.allow_site(&url);
assert!(shields.request_headers(&url).is_empty());
}
#[test]
fn armour_enforce_limits_preserves_built_in_tracker_rules() {
let page = Url::parse("https://news.example/").unwrap();
let tracker = Url::parse("https://stats.google-analytics.com/script.js").unwrap();
let mut shields = Shields {
blocked_hosts: BTreeSet::new(),
..Shields::default()
};
shields.enforce_limits();
assert!(shields.blocked_hosts.contains("google-analytics.com"));
assert!(shields.should_block_request(&page, &tracker));
for index in 0..(DEFAULT_MAX_BLOCKED_HOSTS + 20) {
shields
.blocked_hosts
.insert(format!("custom-{index:04}.example"));
}
shields.enforce_limits();
assert_eq!(shields.blocked_hosts.len(), DEFAULT_MAX_BLOCKED_HOSTS);
assert!(shields.blocked_hosts.contains("google-analytics.com"));
assert!(shields.should_block_request(&page, &tracker));
}
#[test]
fn search_terms_become_search_url() {
let url = parse_navigation_input("rust webview browser").unwrap();
assert_eq!(url.host_str(), Some("www.google.com"));
assert_eq!(
url.query_pairs().find(|(k, _)| k == "q").unwrap().1,
"rust webview browser"
);
}
#[test]
fn navigation_rejects_unsafe_non_web_schemes() {
assert!(parse_navigation_input("file:///etc/passwd").is_err());
assert!(parse_navigation_input("data:text/html,hello").is_err());
assert!(parse_navigation_input("javascript:alert(1)").is_err());
assert_eq!(
parse_navigation_input("about:blank").unwrap().as_str(),
"about:blank"
);
assert_eq!(
parse_navigation_input("cephas://settings")
.unwrap()
.as_str(),
"cephas://settings"
);
}
}