From 96bad93bb275a629950b9db1e84ca32443877198 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sat, 23 May 2026 22:15:37 +0200 Subject: [PATCH] LibWeb: Use adblock-rust in ContentBlocker Replace the local substring matcher with the adblock-rust engine exposed through the dedicated content blocker Rust FFI. Keep the previous engine when a replacement list cannot be parsed. Pass shared list buffers directly into Rust instead of building a duplicate C++ vector of lines first. Generate cosmetic CSS through the Rust matcher, including generic class and id selectors collected from shadow-including descendants. Keep a supplemental index for generic cosmetic selector-list rules. adblock-rust indexes these rules under the first class or id token only, so also key them by later simple class and id selectors in the list. Update ContentBlocker coverage for rule options, exceptions, third-party checks, blob and file URLs, invalid list replacement, filtering toggles, cosmetic CSS, and generic selector-list cosmetics. --- .../LibWeb/ContentBlocker/Rust/src/lib.rs | 239 +++++++++- Libraries/LibWeb/Loader/ContentBlocker.cpp | 443 +++++++++--------- Libraries/LibWeb/Loader/ContentBlocker.h | 42 +- Services/WebContent/ConnectionFromClient.cpp | 28 +- Services/WebContent/main.cpp | 2 +- Tests/LibWeb/TestContentBlocker.cpp | 296 ++++++++---- UI/Android/src/main/cpp/WebContentService.cpp | 2 +- 7 files changed, 653 insertions(+), 399 deletions(-) diff --git a/Libraries/LibWeb/ContentBlocker/Rust/src/lib.rs b/Libraries/LibWeb/ContentBlocker/Rust/src/lib.rs index 9b8d62e8cf..d840db32a7 100644 --- a/Libraries/LibWeb/ContentBlocker/Rust/src/lib.rs +++ b/Libraries/LibWeb/ContentBlocker/Rust/src/lib.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: BSD-2-Clause */ +use std::collections::{HashMap, HashSet}; use std::ffi::c_void; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -13,6 +14,23 @@ pub struct ContentBlockerString { length: usize, } +struct ContentBlockerEngine { + engine: adblock::engine::Engine, + generic_selector_list_rules: GenericSelectorListRules, +} + +#[derive(Default)] +struct GenericSelectorListRules { + always_needed: HashSet, + by_class: HashMap>, + by_id: HashMap>, +} + +enum SelectorKey { + Class(String), + Id(String), +} + fn abort_on_panic R, R>(f: F) -> R { match catch_unwind(AssertUnwindSafe(f)) { Ok(result) => result, @@ -45,11 +63,11 @@ unsafe fn string_from_raw<'a>(bytes: *const u8, len: usize) -> Option<&'a str> { std::str::from_utf8(bytes).ok() } -unsafe fn engine_from_raw<'a>(engine: *const c_void) -> Option<&'a adblock::engine::Engine> { +unsafe fn engine_from_raw<'a>(engine: *const c_void) -> Option<&'a ContentBlockerEngine> { if engine.is_null() { return None; } - Some(unsafe { &*engine.cast::() }) + Some(unsafe { &*engine.cast::() }) } fn string_to_ffi(string: String) -> ContentBlockerString { @@ -67,17 +85,205 @@ fn string_to_ffi(string: String) -> ContentBlockerString { ContentBlockerString { data, length } } -fn cosmetic_css_for_url( - engine: &adblock::engine::Engine, - url: &str, - classes: impl IntoIterator>, - ids: impl IntoIterator>, -) -> String { - let resources = engine.url_cosmetic_resources(url); +fn for_each_selector_list_item(selector: &str, mut callback: impl FnMut(&str)) { + let mut start = 0; + let mut parenthesis_depth: u32 = 0; + let mut bracket_depth: u32 = 0; + let mut quote = None; + let mut escaped = false; + + for (index, character) in selector.char_indices() { + if escaped { + escaped = false; + continue; + } + + if character == '\\' { + escaped = true; + continue; + } + + if let Some(quote_character) = quote { + if character == quote_character { + quote = None; + } + continue; + } + + match character { + '"' | '\'' => quote = Some(character), + '(' => parenthesis_depth += 1, + ')' => parenthesis_depth = parenthesis_depth.saturating_sub(1), + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + ',' if parenthesis_depth == 0 && bracket_depth == 0 => { + callback(&selector[start..index]); + start = index + character.len_utf8(); + } + _ => {} + } + } + + callback(&selector[start..]); +} + +fn selector_key_from_start(selector: &str) -> Option { + let selector = selector.trim_start(); + let marker = selector.chars().next()?; + if marker != '.' && marker != '#' { + return None; + } + + let mut end = marker.len_utf8(); + for (offset, character) in selector[end..].char_indices() { + if character == '\\' { + return None; + } + if !character.is_alphanumeric() && character != '_' && character != '-' { + break; + } + end = marker.len_utf8() + offset + character.len_utf8(); + } + + if end == marker.len_utf8() { + return None; + } + + let key = selector[marker.len_utf8()..end].to_string(); + match marker { + '.' => Some(SelectorKey::Class(key)), + '#' => Some(SelectorKey::Id(key)), + _ => None, + } +} + +impl GenericSelectorListRules { + fn from_rules(rules: &str) -> Self { + let mut selector_list_rules = Self::default(); + + for line in rules.lines() { + let trimmed_line = line.trim(); + if trimmed_line.is_empty() || trimmed_line.starts_with('!') || trimmed_line.starts_with('[') { + continue; + } + + let Ok(filter) = adblock::filters::cosmetic::CosmeticFilter::parse( + trimmed_line, + false, + adblock::resources::PermissionMask::default(), + ) else { + continue; + }; + + let generic_filter = if filter.has_hostname_constraint() { + filter.hidden_generic_rule() + } else { + Some(filter) + }; + + let Some(generic_filter) = generic_filter else { + continue; + }; + let Some(selector) = generic_filter.plain_css_selector() else { + continue; + }; + selector_list_rules.add_selector(selector); + } + + selector_list_rules + } + + fn add_selector(&mut self, selector: &str) { + let mut selector_list_item_count = 0; + let mut has_unkeyable_item = false; + let mut class_keys = HashSet::new(); + let mut id_keys = HashSet::new(); + for_each_selector_list_item(selector, |selector_list_item| { + selector_list_item_count += 1; + + match selector_key_from_start(selector_list_item) { + Some(SelectorKey::Class(class)) => { + class_keys.insert(class); + } + Some(SelectorKey::Id(id)) => { + id_keys.insert(id); + } + None => has_unkeyable_item = true, + } + }); + + if selector_list_item_count < 2 { + return; + } + + let selector = selector.to_string(); + if has_unkeyable_item { + self.always_needed.insert(selector); + return; + } + + for class in class_keys { + self.by_class.entry(class).or_default().push(selector.clone()); + } + for id in id_keys { + self.by_id.entry(id).or_default().push(selector.clone()); + } + } + + fn hidden_selectors( + &self, + classes: impl IntoIterator>, + ids: impl IntoIterator>, + exceptions: &HashSet, + ) -> Vec { + let mut selectors: Vec<_> = self + .always_needed + .iter() + .filter(|selector| !exceptions.contains(*selector)) + .cloned() + .collect(); + + for class in classes { + if let Some(class_selectors) = self.by_class.get(class.as_ref()) { + selectors.extend( + class_selectors + .iter() + .filter(|selector| !exceptions.contains(*selector)) + .cloned(), + ); + } + } + + for id in ids { + if let Some(id_selectors) = self.by_id.get(id.as_ref()) { + selectors.extend( + id_selectors + .iter() + .filter(|selector| !exceptions.contains(*selector)) + .cloned(), + ); + } + } + + selectors + } +} + +fn cosmetic_css_for_url(engine: &ContentBlockerEngine, url: &str, classes: &[&str], ids: &[&str]) -> String { + let resources = engine.engine.url_cosmetic_resources(url); let mut selectors = resources.hide_selectors; if !resources.generichide { - selectors.extend(engine.hidden_class_id_selectors(classes, ids, &resources.exceptions)); + selectors.extend(engine.engine.hidden_class_id_selectors( + classes.iter().copied(), + ids.iter().copied(), + &resources.exceptions, + )); + selectors.extend(engine.generic_selector_list_rules.hidden_selectors( + classes.iter().copied(), + ids.iter().copied(), + &resources.exceptions, + )); } let mut selector_styles: Vec<_> = selectors @@ -122,6 +328,11 @@ pub unsafe extern "C" fn rust_content_blocker_create(rules: *const u8, rules_len }; let engine = adblock::engine::Engine::from_rules(rules.lines(), adblock::lists::ParseOptions::default()); + let generic_selector_list_rules = GenericSelectorListRules::from_rules(rules); + let engine = ContentBlockerEngine { + engine, + generic_selector_list_rules, + }; Box::into_raw(Box::new(engine)).cast() }) } @@ -132,7 +343,7 @@ pub unsafe extern "C" fn rust_content_blocker_create(rules: *const u8, rules_len pub unsafe extern "C" fn rust_content_blocker_free(engine: *mut c_void) { abort_on_panic(|| { if !engine.is_null() { - drop(unsafe { Box::from_raw(engine.cast::()) }); + drop(unsafe { Box::from_raw(engine.cast::()) }); } }); } @@ -168,7 +379,7 @@ pub unsafe extern "C" fn rust_content_blocker_matches( return false; }; - engine.check_network_request(&request).matched + engine.engine.check_network_request(&request).matched }) } @@ -200,7 +411,9 @@ pub unsafe extern "C" fn rust_content_blocker_cosmetic_css( return string_to_ffi(String::new()); }; - string_to_ffi(cosmetic_css_for_url(engine, url, classes.lines(), ids.lines())) + let classes: Vec<_> = classes.lines().collect(); + let ids: Vec<_> = ids.lines().collect(); + string_to_ffi(cosmetic_css_for_url(engine, url, &classes, &ids)) }) } diff --git a/Libraries/LibWeb/Loader/ContentBlocker.cpp b/Libraries/LibWeb/Loader/ContentBlocker.cpp index e4f62b50b1..5728f40bd6 100644 --- a/Libraries/LibWeb/Loader/ContentBlocker.cpp +++ b/Libraries/LibWeb/Loader/ContentBlocker.cpp @@ -1,17 +1,16 @@ /* - * Copyright (c) 2021, Andreas Kling - * Copyright (c) 2025, Tim Ledbetter + * Copyright (c) 2026-present, the Ladybird developers. * * SPDX-License-Identifier: BSD-2-Clause */ -#include -#include -#include -#include +#include #include +#include #include +#include #include +#include #include namespace Web { @@ -24,23 +23,153 @@ ContentBlocker& ContentBlocker::the() ContentBlocker::ContentBlocker() = default; -ContentBlocker::~ContentBlocker() = default; +ContentBlocker::~ContentBlocker() +{ + ContentBlocking::FFI::rust_content_blocker_free(m_engine); +} + +static StringView resource_type_to_adblock_request_type(ContentBlocker::ResourceType type) +{ + switch (type) { + case ContentBlocker::ResourceType::Document: + return "document"sv; + case ContentBlocker::ResourceType::Font: + return "font"sv; + case ContentBlocker::ResourceType::Image: + return "image"sv; + case ContentBlocker::ResourceType::Media: + return "media"sv; + case ContentBlocker::ResourceType::Object: + return "object"sv; + case ContentBlocker::ResourceType::Other: + return "other"sv; + case ContentBlocker::ResourceType::Ping: + return "ping"sv; + case ContentBlocker::ResourceType::Script: + return "script"sv; + case ContentBlocker::ResourceType::Stylesheet: + return "stylesheet"sv; + case ContentBlocker::ResourceType::Subdocument: + return "subdocument"sv; + case ContentBlocker::ResourceType::WebSocket: + return "websocket"sv; + case ContentBlocker::ResourceType::XMLHttpRequest: + return "xmlhttprequest"sv; + } + VERIFY_NOT_REACHED(); +} + +static ByteString serialized_url(URL::URL const& url) +{ + return url.serialize().to_byte_string(); +} + +static ByteString serialized_url_for_matching(URL::URL const& url) +{ + if (url.scheme() == "file"sv) + return ByteString::formatted("http://local-file.invalid/{}", serialized_url(url)); + return serialized_url(url); +} + +static String take_rust_string(ContentBlocking::FFI::ContentBlockerString rust_string) +{ + if (!rust_string.data) + return {}; + + ArmedScopeGuard free_string = [&] { + ContentBlocking::FFI::rust_content_blocker_free_string(rust_string.data, rust_string.length); + }; + + auto maybe_string = String::from_utf8({ reinterpret_cast(rust_string.data), rust_string.length }); + if (maybe_string.is_error()) + return {}; + return maybe_string.release_value(); +} + +static ErrorOr join_lines(ReadonlySpan lines) +{ + StringBuilder builder; + for (auto const& line : lines) { + builder.append(line); + builder.append('\n'); + } + return builder.to_string(); +} + +static bool line_looks_like_supported_cosmetic_rule(StringView line) +{ + auto trimmed_line = line.trim_whitespace(); + if (trimmed_line.is_empty() || trimmed_line.starts_with('!') || trimmed_line.starts_with('[')) + return false; + + auto sharp_index = trimmed_line.find('#'); + if (!sharp_index.has_value()) + return false; + + auto after_sharp_index = *sharp_index + 1; + if (after_sharp_index >= trimmed_line.length()) + return false; + + auto second_sharp_index = trimmed_line.find('#', after_sharp_index); + if (!second_sharp_index.has_value()) + return false; + + auto between_sharps = trimmed_line.substring_view(after_sharp_index, *second_sharp_index - after_sharp_index); + if (between_sharps.starts_with('@')) { + if (*sharp_index == 0) + return false; + between_sharps = between_sharps.substring_view(1); + } + if (between_sharps.starts_with('?')) + between_sharps = between_sharps.substring_view(1); + + return between_sharps.is_empty(); +} + +static bool rules_contain_cosmetic_rules(ReadonlyBytes rules_bytes) +{ + bool has_cosmetic_rules = false; + StringView { rules_bytes }.for_each_split_view('\n', SplitBehavior::Nothing, [&](StringView line) { + if (line_looks_like_supported_cosmetic_rule(line)) + has_cosmetic_rules = true; + }); + return has_cosmetic_rules; +} + +ErrorOr ContentBlocker::set_patterns(ReadonlySpan patterns) +{ + StringBuilder builder; + for (auto const& pattern : patterns) { + if (pattern.is_empty()) + continue; + builder.append(pattern); + builder.append('\n'); + } + + auto patterns_string = TRY(builder.to_string()); + auto patterns_bytes = patterns_string.bytes_as_string_view().bytes(); + return set_rules_from_bytes(patterns_bytes); +} + +ErrorOr ContentBlocker::set_rules_from_bytes(ReadonlyBytes rules_bytes) +{ + auto* engine = ContentBlocking::FFI::rust_content_blocker_create( + rules_bytes.data(), + rules_bytes.size()); + if (!engine) + return Error::from_string_literal("Failed to create content blocker"); + + auto has_cosmetic_rules = rules_contain_cosmetic_rules(rules_bytes); + + ContentBlocking::FFI::rust_content_blocker_free(m_engine); + m_engine = engine; + m_has_cosmetic_rules = has_cosmetic_rules; + return {}; +} bool ContentBlocker::is_filtered(URL::URL const& url) const { - if (!filtering_enabled()) - return false; - - if (url.scheme() == "data") - return false; - return contains(url.to_string()); -} - -bool ContentBlocker::is_filtered(URL::URL const& url, URL::URL const& source_url, ResourceType resource_type) const -{ - (void)source_url; - (void)resource_type; - return is_filtered(url); + return is_filtered(url, url, ResourceType::Other); } bool ContentBlocker::is_filtered(URL::URL const& url, URL::URL const& source_url, Optional const& destination, Optional const& initiator_type, Fetch::Infrastructure::Request::Mode mode) const @@ -48,90 +177,87 @@ bool ContentBlocker::is_filtered(URL::URL const& url, URL::URL const& source_url return is_filtered(url, source_url_for_matching(source_url), resource_type_from_fetch_metadata(destination, initiator_type, mode)); } -bool ContentBlocker::contains(StringView text) const +bool ContentBlocker::is_filtered(URL::URL const& url, URL::URL const& source_url, ResourceType resource_type) const { - if (!m_matcher) - return false; - return m_matcher->contains(text); -} - -ErrorOr ContentBlocker::set_patterns(ReadonlySpan patterns) -{ - Vector network_patterns; - m_cosmetic_rules.clear(); - - for (auto const& pattern : patterns) { - auto pattern_view = pattern.bytes_as_string_view(); - auto cosmetic_marker = pattern_view.find("##"sv); - if (!cosmetic_marker.has_value()) { - network_patterns.append(pattern); - continue; - } - - auto selector = pattern_view.substring_view(cosmetic_marker.value() + 2); - if (selector.is_empty()) - continue; - - auto domains = pattern_view.substring_view(0, cosmetic_marker.value()); - if (domains.is_empty()) { - CosmeticRule rule; - rule.selector = TRY(String::from_utf8(selector)); - m_cosmetic_rules.append(move(rule)); - continue; - } - - for (auto domain : domains.split_view(',')) { - if (domain.is_empty()) - continue; - CosmeticRule rule; - rule.domain = TRY(String::from_utf8(domain)); - rule.selector = TRY(String::from_utf8(selector)); - m_cosmetic_rules.append(move(rule)); - } - } - - m_matcher = make(network_patterns); - return {}; -} - -static bool cosmetic_rule_domain_matches(StringView domain, URL::URL const& url) -{ - auto const& host = url.host(); - if (!host.has_value()) + if (!filtering_enabled() || !m_engine) return false; - auto host_string = host->serialize(); - auto host_view = host_string.bytes_as_string_view(); - if (host_view == domain) - return true; - - if (!host_view.ends_with(domain)) - return false; - if (host_view.length() <= domain.length()) + if (url.scheme() == "data"sv) return false; - return host_view[host_view.length() - domain.length() - 1] == '.'; -} + auto url_string = serialized_url_for_matching(url); + auto normalized_source_url = source_url_for_matching(source_url); + auto source_url_string = serialized_url_for_matching(normalized_source_url); + auto request_type = resource_type_to_adblock_request_type(resource_type); -String ContentBlocker::cosmetic_style_sheet_for_document(DOM::Document const& document) const -{ - return cosmetic_style_sheet_for_url(document.fallback_base_url()); + return ContentBlocking::FFI::rust_content_blocker_matches( + m_engine, + reinterpret_cast(url_string.characters()), + url_string.length(), + reinterpret_cast(source_url_string.characters()), + source_url_string.length(), + reinterpret_cast(request_type.characters_without_null_termination()), + request_type.length()); } String ContentBlocker::cosmetic_style_sheet_for_url(URL::URL const& url) const { - if (!filtering_enabled()) + return cosmetic_style_sheet_for_url(url, {}, {}); +} + +String ContentBlocker::cosmetic_style_sheet_for_url(URL::URL const& url, ReadonlySpan classes, ReadonlySpan ids) const +{ + if (!filtering_enabled() || !m_engine || !m_has_cosmetic_rules) return {}; - StringBuilder builder; - for (auto const& rule : m_cosmetic_rules) { - if (rule.domain.has_value() && !cosmetic_rule_domain_matches(rule.domain->bytes_as_string_view(), url)) - continue; + auto url_string = serialized_url(url); + auto classes_string = join_lines(classes); + if (classes_string.is_error()) + return {}; - builder.append(rule.selector); - builder.append(" { display: none !important; }\n"sv); - } - return builder.to_string_without_validation(); + auto ids_string = join_lines(ids); + if (ids_string.is_error()) + return {}; + + auto classes_bytes = classes_string.value().bytes_as_string_view(); + auto ids_bytes = ids_string.value().bytes_as_string_view(); + + return take_rust_string(ContentBlocking::FFI::rust_content_blocker_cosmetic_css( + m_engine, + reinterpret_cast(url_string.characters()), + url_string.length(), + reinterpret_cast(classes_bytes.characters_without_null_termination()), + classes_bytes.length(), + reinterpret_cast(ids_bytes.characters_without_null_termination()), + ids_bytes.length())); +} + +String ContentBlocker::cosmetic_style_sheet_for_document(DOM::Document const& document) const +{ + if (!filtering_enabled() || !m_engine || !m_has_cosmetic_rules) + return {}; + + Vector classes; + Vector ids; + const_cast(document).for_each_shadow_including_descendant([&](DOM::Node& node) { + auto* element = as_if(node); + if (!element) + return TraversalDecision::Continue; + + if (auto const& id = element->id(); id.has_value()) { + if (auto id_string = id->to_string(); !id_string.is_empty()) + ids.append(move(id_string)); + } + + for (auto const& class_name : element->class_names()) { + if (auto class_string = class_name.to_string(); !class_string.is_empty()) + classes.append(move(class_string)); + } + + return TraversalDecision::Continue; + }); + + return cosmetic_style_sheet_for_url(document.fallback_base_url(), classes, ids); } ContentBlocker::ResourceType ContentBlocker::resource_type_from_fetch_metadata(Optional const& destination, Optional const& initiator_type, Fetch::Infrastructure::Request::Mode mode) @@ -204,6 +330,7 @@ ContentBlocker::ResourceType ContentBlocker::resource_type_from_fetch_metadata(O case Request::InitiatorType::Script: return ResourceType::Script; case Request::InitiatorType::CSS: + return ResourceType::Stylesheet; case Request::InitiatorType::EarlyHint: case Request::InitiatorType::Body: case Request::InitiatorType::Input: @@ -229,138 +356,4 @@ URL::URL ContentBlocker::source_url_for_matching(URL::URL const& source_url) return parsed_url.release_value(); } -AsciiStringMatcher::AsciiStringMatcher(ReadonlySpan patterns) -{ - struct BuildTimeNode { - Vector children; - bool is_output { false }; - }; - - Vector build_time_nodes; - build_time_nodes.append({}); - - for (u32 i = 0; i < patterns.size(); ++i) { - auto const& pattern = patterns[i]; - u32 node = 0; - for (u8 ch : pattern.bytes_as_string_view()) { - VERIFY(is_ascii(ch)); - auto it = build_time_nodes[node].children.find_if( - [ch](Transition const& t) { return t.character == ch; }); - - if (it != build_time_nodes[node].children.end()) { - node = it->next_state; - } else { - u32 new_node = build_time_nodes.size(); - build_time_nodes.append({}); - build_time_nodes[node].children.empend(ch, new_node); - node = new_node; - } - } - - if (!build_time_nodes[node].is_output) - build_time_nodes[node].is_output = true; - } - - Vector failure_links; - failure_links.resize(build_time_nodes.size()); - - Queue queue; - for (auto const& transition : build_time_nodes[0].children) { - u32 child = transition.next_state; - failure_links[child] = 0; - queue.enqueue(child); - } - - while (!queue.is_empty()) { - u32 current = queue.dequeue(); - for (auto& [character, child] : build_time_nodes[current].children) { - u32 failure_link = failure_links[current]; - while (failure_link != 0) { - auto it = build_time_nodes[failure_link].children.find_if( - [character](Transition const& tr) { return tr.character == character; }); - if (it != build_time_nodes[failure_link].children.end()) { - failure_link = it->next_state; - break; - } - failure_link = failure_links[failure_link]; - } - - u32 next_failure_link = failure_link; - failure_links[child] = next_failure_link; - - bool inherited = build_time_nodes[next_failure_link].is_output; - if (inherited && !build_time_nodes[child].is_output) - build_time_nodes[child].is_output = true; - - queue.enqueue(child); - } - } - - for (auto& node : build_time_nodes) { - quick_sort(node.children, [](Transition const& a, Transition const& b) { - return a.character < b.character; - }); - } - - m_nodes.resize(build_time_nodes.size()); - m_transitions.clear_with_capacity(); - - u32 transition_index = 0; - for (u32 i = 0; i < build_time_nodes.size(); ++i) { - auto& build_time_node = build_time_nodes[i]; - m_nodes[i].first_transition = transition_index; - m_nodes[i].transition_count = build_time_node.children.size(); - m_nodes[i].output = build_time_node.is_output; - m_transitions.extend(build_time_node.children); - - transition_index += build_time_node.children.size(); - } -} - -bool AsciiStringMatcher::contains(StringView text) const -{ - if (m_nodes.is_empty()) - return false; - - auto get_children = [this](u32 state) -> ReadonlySpan { - return m_transitions.span().slice(m_nodes[state].first_transition, m_nodes[state].transition_count); - }; - - u32 state = 0; - for (u8 ch : text.bytes()) { - auto const& children = get_children(state); - - auto const* found = AK::binary_search( - children, - ch, - nullptr, - [](u8 needle, Transition const& transition) { - if (needle > transition.character) - return needle < transition.character ? -1 : 1; - return needle < transition.character ? -1 : 0; - }); - - if (!found) { - state = 0; - auto const& root_children = get_children(0); - found = AK::binary_search( - root_children, - ch, - nullptr, - [](u8 needle, Transition const& transition) { - if (needle > transition.character) - return needle < transition.character ? -1 : 1; - return needle < transition.character ? -1 : 0; - }); - if (!found) - continue; - } - - state = found->next_state; - if (m_nodes[state].output) - return true; - } - return false; -} - } diff --git a/Libraries/LibWeb/Loader/ContentBlocker.h b/Libraries/LibWeb/Loader/ContentBlocker.h index e7158f9067..6889005ba5 100644 --- a/Libraries/LibWeb/Loader/ContentBlocker.h +++ b/Libraries/LibWeb/Loader/ContentBlocker.h @@ -7,10 +7,9 @@ #pragma once +#include #include -#include #include -#include #include #include #include @@ -18,30 +17,9 @@ namespace Web { -class AsciiStringMatcher { -public: - explicit AsciiStringMatcher(ReadonlySpan patterns); - - bool contains(StringView text) const; - -private: - struct Transition { - u8 character { 0 }; - u32 next_state { 0 }; - }; - - struct Node { - u32 first_transition { 0 }; - u8 transition_count { 0 }; - bool output { false }; - }; - - Vector m_nodes; - Vector m_transitions; -}; - class WEB_API ContentBlocker { AK_MAKE_NONCOPYABLE(ContentBlocker); + AK_MAKE_NONMOVABLE(ContentBlocker); public: enum class ResourceType : u8 { @@ -61,6 +39,8 @@ public: static ContentBlocker& the(); + bool has_rules() const { return m_engine != nullptr; } + bool has_cosmetic_rules() const { return m_has_cosmetic_rules; } bool filtering_enabled() const { return m_filtering_enabled; } void set_filtering_enabled(bool const enabled) { m_filtering_enabled = enabled; } @@ -68,10 +48,11 @@ public: bool is_filtered(URL::URL const&, URL::URL const& source_url, ResourceType) const; bool is_filtered(URL::URL const&, URL::URL const& source_url, Optional const&, Optional const&, Fetch::Infrastructure::Request::Mode) const; ErrorOr set_patterns(ReadonlySpan); + ErrorOr set_rules_from_bytes(ReadonlyBytes); - bool has_cosmetic_rules() const { return !m_cosmetic_rules.is_empty(); } String cosmetic_style_sheet_for_document(DOM::Document const&) const; String cosmetic_style_sheet_for_url(URL::URL const&) const; + String cosmetic_style_sheet_for_url(URL::URL const&, ReadonlySpan classes, ReadonlySpan ids) const; static ResourceType resource_type_from_fetch_metadata(Optional const&, Optional const&, Fetch::Infrastructure::Request::Mode); static URL::URL source_url_for_matching(URL::URL const&); @@ -80,16 +61,9 @@ private: ContentBlocker(); ~ContentBlocker(); - bool contains(StringView text) const; - - struct CosmeticRule { - Optional domain; - String selector; - }; - bool m_filtering_enabled { true }; - OwnPtr m_matcher; - Vector m_cosmetic_rules; + bool m_has_cosmetic_rules { false }; + void* m_engine { nullptr }; }; } diff --git a/Services/WebContent/ConnectionFromClient.cpp b/Services/WebContent/ConnectionFromClient.cpp index 5c606a4fc9..c0338cce70 100644 --- a/Services/WebContent/ConnectionFromClient.cpp +++ b/Services/WebContent/ConnectionFromClient.cpp @@ -1172,33 +1172,15 @@ void ConnectionFromClient::paste(u64 page_id, Utf16String text) page->page().focused_navigable().paste(text); } -static ErrorOr> parse_content_blocker_patterns(Core::AnonymousBuffer const& patterns_buffer) -{ - Vector patterns; - - for (auto line : StringView { patterns_buffer.bytes() }.split_view('\n', SplitBehavior::Nothing)) { - if (line.ends_with('\r')) - line = line.substring_view(0, line.length() - 1); - if (line.is_empty()) - continue; - - patterns.append(TRY(String::from_utf8(line))); - } - - return patterns; -} - void ConnectionFromClient::set_content_blockers(u64 page_id, Core::AnonymousBuffer patterns_buffer) { - auto patterns_or_error = parse_content_blocker_patterns(patterns_buffer); - if (patterns_or_error.is_error()) { - dbgln("Failed to set content blockers: {}", patterns_or_error.error()); - return; - } - auto& blocker = Web::ContentBlocker::the(); auto had_cosmetic_rules = blocker.has_cosmetic_rules(); - blocker.set_patterns(patterns_or_error.value()).release_value_but_fixme_should_propagate_errors(); + auto result = blocker.set_rules_from_bytes(patterns_buffer.bytes()); + if (result.is_error()) { + dbgln("Failed to set content blockers: {}", result.error()); + return; + } if (had_cosmetic_rules || blocker.has_cosmetic_rules()) { if (auto page = this->page(page_id); page.has_value()) diff --git a/Services/WebContent/main.cpp b/Services/WebContent/main.cpp index 132ecde3d4..5cbbc854cb 100644 --- a/Services/WebContent/main.cpp +++ b/Services/WebContent/main.cpp @@ -296,7 +296,7 @@ static ErrorOr load_content_blockers(StringView config_path) continue; auto pattern = TRY(String::from_utf8(line)); - TRY(patterns.try_append(move(pattern))); + patterns.append(move(pattern)); } auto& content_blocker = Web::ContentBlocker::the(); diff --git a/Tests/LibWeb/TestContentBlocker.cpp b/Tests/LibWeb/TestContentBlocker.cpp index 46d8bd0b95..c5b6d56f01 100644 --- a/Tests/LibWeb/TestContentBlocker.cpp +++ b/Tests/LibWeb/TestContentBlocker.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include #include #include @@ -11,10 +12,11 @@ namespace Web { -static ContentBlocker& make_blocker(Vector patterns) +static ContentBlocker& make_blocker(Vector rules) { auto& blocker = ContentBlocker::the(); - MUST(blocker.set_patterns(patterns)); + MUST(blocker.set_patterns(rules)); + blocker.set_filtering_enabled(true); return blocker; } @@ -35,95 +37,71 @@ TEST_CASE(empty_pattern_list) TEST_CASE(basic_blocking) { - Vector patterns = { - "ads."_string, - "?banner"_string, - "tracker"_string + Vector rules = { + "||ads.example.com^"_string, + "/banner.js"_string, }; - auto& blocker = make_blocker(move(patterns)); + auto& blocker = make_blocker(move(rules)); + auto source_url = url("https://example.com/"sv); - EXPECT(blocker.is_filtered(url("https://example.com/ads.js"sv))); - EXPECT(blocker.is_filtered(url("http://site.com/page.html?banner=true"sv))); - EXPECT(blocker.is_filtered(url("https://tracker.example.org/ping"sv))); - EXPECT(!blocker.is_filtered(url("https://ds.example.com/page.html"sv))); + EXPECT(blocker.is_filtered(url("https://ads.example.com/script.js"sv), source_url, ContentBlocker::ResourceType::Script)); + EXPECT(blocker.is_filtered(url("https://static.example.com/banner.js"sv), source_url, ContentBlocker::ResourceType::Script)); + EXPECT(!blocker.is_filtered(url("https://example.com/page.html"sv), source_url, ContentBlocker::ResourceType::Document)); } TEST_CASE(data_urls_exempt) { - Vector patterns = { + Vector rules = { { "data:"_string }, - { "evil.com"_string } + { "||evil.com^"_string } }; - auto& blocker = make_blocker(move(patterns)); + auto& blocker = make_blocker(move(rules)); + auto source_url = url("https://example.com/"sv); EXPECT(!blocker.is_filtered(url("data:text/plain,hello"sv))); EXPECT(!blocker.is_filtered(url("data:image/png;base64,abc123"sv))); - EXPECT(blocker.is_filtered(url("https://evil.com/script.js"sv))); + EXPECT(blocker.is_filtered(url("https://evil.com/script.js"sv), source_url, ContentBlocker::ResourceType::Script)); +} + +TEST_CASE(invalid_filter_bytes_keep_previous_rules) +{ + Vector rules = { + { "||ads.example.com^"_string }, + }; + + auto& blocker = make_blocker(move(rules)); + auto source_url = url("https://example.com/"sv); + + EXPECT(blocker.is_filtered(url("https://ads.example.com/script.js"sv), source_url, ContentBlocker::ResourceType::Script)); + + Array invalid_utf8 { 0xff }; + auto result = blocker.set_rules_from_bytes(invalid_utf8.span()); + EXPECT(result.is_error()); + + EXPECT(blocker.is_filtered(url("https://ads.example.com/script.js"sv), source_url, ContentBlocker::ResourceType::Script)); } TEST_CASE(disable_filtering) { - Vector patterns = { - { "example.com"_string }, + Vector rules = { + { "||example.com^"_string }, { "##.ad"_string } }; - auto& blocker = make_blocker(move(patterns)); + auto& blocker = make_blocker(move(rules)); blocker.set_filtering_enabled(false); + Vector classes = { "ad"_string }; + Vector ids; EXPECT(!blocker.is_filtered(url("https://example.com"sv))); EXPECT(!blocker.is_filtered(url("http://example.com/ads"sv))); - EXPECT(blocker.cosmetic_style_sheet_for_url(url("https://example.com"sv)).is_empty()); + EXPECT(blocker.cosmetic_style_sheet_for_url(url("https://example.com"sv), classes.span(), ids.span()).is_empty()); blocker.set_filtering_enabled(true); EXPECT(blocker.is_filtered(url("https://example.com"sv))); - EXPECT(!blocker.cosmetic_style_sheet_for_url(url("https://example.com"sv)).is_empty()); -} - -TEST_CASE(substring_matches) -{ - Vector patterns = { - { "ads"_string }, - { "ad/"_string } - }; - - auto& blocker = make_blocker(move(patterns)); - - EXPECT(blocker.is_filtered(url("https://site.com/ads/banner.jpg"sv))); - EXPECT(blocker.is_filtered(url("http://marketing.com/ad/page"sv))); - EXPECT(!blocker.is_filtered(url("https://site.com/content/article.html"sv))); - EXPECT(!blocker.is_filtered(url("http://advancedtech.com/home"sv))); -} - -TEST_CASE(file_scheme_can_be_filtered) -{ - Vector patterns = { - { "secret"_string }, - { ".txt"_string } - }; - - auto& blocker = make_blocker(move(patterns)); - - EXPECT(blocker.is_filtered(url("file:///home/user/secret.txt"sv))); - EXPECT(!blocker.is_filtered(url("file:///home/user/document.pdf"sv))); -} - -TEST_CASE(query_parameters_and_fragments) -{ - Vector patterns = { - { "#ad="_string }, - { "?ad="_string }, - { "#sponsored"_string } - }; - - auto& blocker = make_blocker(move(patterns)); - - EXPECT(blocker.is_filtered(url("https://site.com/page?ad=123"sv))); - EXPECT(blocker.is_filtered(url("https://site.com/page#ad=456"sv))); - EXPECT(blocker.is_filtered(url("https://site.com/page?ref=home&ad=1#sponsored"sv))); - EXPECT(!blocker.is_filtered(url("https://site.com/page?ref=home"sv))); + EXPECT(!blocker.cosmetic_style_sheet_for_url(url("https://example.com"sv), classes.span(), ids.span()).is_empty()); } TEST_CASE(fetch_metadata_maps_to_resource_type) @@ -145,60 +123,174 @@ TEST_CASE(fetch_metadata_maps_to_resource_type) EXPECT(resource_type({}, {}, Request::Mode::WebSocket) == ResourceType::WebSocket); } -TEST_CASE(blob_source_urls_are_normalized_for_matching) +TEST_CASE(resource_type_options) { - auto normalized = ContentBlocker::source_url_for_matching(url("blob:https://example.com/object-id"sv)); - EXPECT_EQ(normalized.to_string(), "https://example.com/object-id"sv); + using Request = Fetch::Infrastructure::Request; - auto non_blob = ContentBlocker::source_url_for_matching(url("https://example.com/page"sv)); - EXPECT_EQ(non_blob.to_string(), "https://example.com/page"sv); -} - -TEST_CASE(contextual_filtering_uses_existing_matcher) -{ - Vector patterns = { - { "blocked.js"_string } + Vector rules = { + { "||example.com/ad^$image"_string }, + { "||example.com/font^$font"_string }, + { "||example.com/document^$document"_string }, + { "||example.com/fetch^$xmlhttprequest"_string }, }; - auto& blocker = make_blocker(move(patterns)); + auto& blocker = make_blocker(move(rules)); + auto source_url = url("https://example.com/"sv); + EXPECT(blocker.is_filtered(url("https://example.com/ad"sv), source_url, ContentBlocker::ResourceType::Image)); + EXPECT(!blocker.is_filtered(url("https://example.com/ad"sv), source_url, ContentBlocker::ResourceType::Script)); EXPECT(blocker.is_filtered( - url("https://tracker.example/blocked.js"sv), - url("https://example.com/page"sv), - Fetch::Infrastructure::Request::Destination::Script, - Fetch::Infrastructure::Request::InitiatorType::CSS, - Fetch::Infrastructure::Request::Mode::NoCORS)); + url("https://example.com/ad"sv), + source_url, + Request::Destination::Image, + Request::InitiatorType::CSS, + Request::Mode::NoCORS)); + EXPECT(blocker.is_filtered( + url("https://example.com/font"sv), + source_url, + Request::Destination::Font, + Request::InitiatorType::CSS, + Request::Mode::NoCORS)); + EXPECT(blocker.is_filtered( + url("https://example.com/document"sv), + source_url, + Request::Destination::Document, + Optional {}, + Request::Mode::Navigate)); + EXPECT(blocker.is_filtered( + url("https://example.com/fetch"sv), + source_url, + Optional {}, + Request::InitiatorType::Fetch, + Request::Mode::CORS)); } -TEST_CASE(cosmetic_rules_generate_user_css) +TEST_CASE(file_scheme_fallback) { - Vector patterns = { - { "blocked.js"_string }, - { "##.ad"_string }, - { "example.com##.sponsored"_string }, - { "other.example##.other"_string } + Vector rules = { + { "secret"_string }, }; - auto& blocker = make_blocker(move(patterns)); + auto& blocker = make_blocker(move(rules)); - EXPECT(blocker.has_cosmetic_rules()); - EXPECT(blocker.is_filtered(url("https://tracker.example/blocked.js"sv))); - EXPECT(!blocker.is_filtered(url("https://tracker.example/##.ad"sv))); - - auto style_sheet = blocker.cosmetic_style_sheet_for_url(url("https://www.example.com/page"sv)); - EXPECT(style_sheet.contains(".ad { display: none !important; }"sv)); - EXPECT(style_sheet.contains(".sponsored { display: none !important; }"sv)); - EXPECT(!style_sheet.contains(".other { display: none !important; }"sv)); + EXPECT(blocker.is_filtered(url("file:///home/user/secret.txt"sv))); + EXPECT(!blocker.is_filtered(url("file:///home/user/public.txt"sv))); } -TEST_CASE(clearing_patterns_clears_cosmetic_rules) +TEST_CASE(third_party_option) { - auto& blocker = make_blocker({ "##.ad"_string }); - EXPECT(blocker.has_cosmetic_rules()); + Vector rules = { + { "||tracker.example^$third-party"_string }, + }; - MUST(blocker.set_patterns({})); - EXPECT(!blocker.has_cosmetic_rules()); - EXPECT(blocker.cosmetic_style_sheet_for_url(url("https://example.com/"sv)).is_empty()); + auto& blocker = make_blocker(move(rules)); + + EXPECT(blocker.is_filtered(url("https://tracker.example/pixel.gif"sv), url("https://site.example/"sv), ContentBlocker::ResourceType::Image)); + EXPECT(!blocker.is_filtered(url("https://tracker.example/pixel.gif"sv), url("https://www.tracker.example/"sv), ContentBlocker::ResourceType::Image)); +} + +TEST_CASE(blob_source_url_uses_embedded_url) +{ + Vector rules = { + { "||tracker.example/third-party.gif$third-party"_string }, + { "||tracker.example/first-party.gif$first-party"_string }, + { "||ads.example/domain.js$domain=www.tracker.example"_string }, + }; + + auto& blocker = make_blocker(move(rules)); + auto same_site_blob_source = url("blob:https://www.tracker.example/4dd3d7ea-8bd7-4fe0-a121-79c18e2be4b2"sv); + auto cross_site_blob_source = url("blob:https://site.example/4dd3d7ea-8bd7-4fe0-a121-79c18e2be4b2"sv); + + EXPECT(!blocker.is_filtered(url("https://tracker.example/third-party.gif"sv), same_site_blob_source, ContentBlocker::ResourceType::Image)); + EXPECT(blocker.is_filtered(url("https://tracker.example/first-party.gif"sv), same_site_blob_source, ContentBlocker::ResourceType::Image)); + EXPECT(blocker.is_filtered(url("https://tracker.example/third-party.gif"sv), cross_site_blob_source, ContentBlocker::ResourceType::Image)); + EXPECT(!blocker.is_filtered(url("https://tracker.example/first-party.gif"sv), cross_site_blob_source, ContentBlocker::ResourceType::Image)); + EXPECT(blocker.is_filtered(url("https://ads.example/domain.js"sv), same_site_blob_source, ContentBlocker::ResourceType::Script)); + EXPECT(!blocker.is_filtered(url("https://ads.example/domain.js"sv), cross_site_blob_source, ContentBlocker::ResourceType::Script)); +} + +TEST_CASE(document_navigation_is_first_party_to_itself) +{ + Vector rules = { + { "||localhost^$third-party,document"_string }, + }; + + auto& blocker = make_blocker(move(rules)); + auto target_url = url("http://localhost:1234/content-blocker-target"sv); + + EXPECT(blocker.is_filtered(target_url, url("https://source.example/"sv), ContentBlocker::ResourceType::Document)); + EXPECT(!blocker.is_filtered(target_url, target_url, ContentBlocker::ResourceType::Document)); +} + +TEST_CASE(exception_rules) +{ + Vector rules = { + { "||ads.example.com^"_string }, + { "@@||ads.example.com/allowed.js"_string }, + }; + + auto& blocker = make_blocker(move(rules)); + auto source_url = url("https://example.com/"sv); + + EXPECT(blocker.is_filtered(url("https://ads.example.com/blocked.js"sv), source_url, ContentBlocker::ResourceType::Script)); + EXPECT(!blocker.is_filtered(url("https://ads.example.com/allowed.js"sv), source_url, ContentBlocker::ResourceType::Script)); +} + +TEST_CASE(cosmetic_style_sheet) +{ + Vector rules = { + { "example.com##.ad-banner"_string }, + { "example.com###sponsor"_string }, + { "example.com##.styled-sponsor:style(visibility: hidden)"_string }, + { "##.generic-ad"_string }, + { "###generic-sponsor"_string }, + }; + + auto& blocker = make_blocker(move(rules)); + Vector classes = { "generic-ad"_string }; + Vector ids = { "generic-sponsor"_string }; + + auto style_sheet = blocker.cosmetic_style_sheet_for_url(url("https://example.com/"sv), classes, ids); + + EXPECT(style_sheet.contains(".ad-banner { display: none !important; }"sv)); + EXPECT(style_sheet.contains("#sponsor { display: none !important; }"sv)); + EXPECT(style_sheet.contains(".styled-sponsor { visibility: hidden; }"sv)); + EXPECT(style_sheet.contains(".generic-ad { display: none !important; }"sv)); + EXPECT(style_sheet.contains("#generic-sponsor { display: none !important; }"sv)); +} + +TEST_CASE(generic_cosmetic_selector_lists_match_later_selectors) +{ + Vector rules = { + { "##.first-ad-class, .second-ad-class"_string }, + { "##.first-ad-id, #second-ad-id"_string }, + { "##.class-keyed-arm, [data-ad]"_string }, + { "##.class-keyed-arm, div.sponsor"_string }, + }; + + auto& blocker = make_blocker(move(rules)); + Vector classes = { "second-ad-class"_string }; + Vector ids = { "second-ad-id"_string }; + Vector no_classes; + Vector no_ids; + + auto style_sheet = blocker.cosmetic_style_sheet_for_url(url("https://example.com/"sv), classes, ids); + auto style_sheet_without_class_or_id_hints = blocker.cosmetic_style_sheet_for_url(url("https://example.com/"sv), no_classes, no_ids); + + EXPECT(style_sheet.contains(".first-ad-class, .second-ad-class { display: none !important; }"sv)); + EXPECT(style_sheet.contains(".first-ad-id, #second-ad-id { display: none !important; }"sv)); + EXPECT(style_sheet_without_class_or_id_hints.contains(".class-keyed-arm, [data-ad] { display: none !important; }"sv)); + EXPECT(style_sheet_without_class_or_id_hints.contains(".class-keyed-arm, div.sponsor { display: none !important; }"sv)); +} + +TEST_CASE(cosmetic_rule_detection) +{ + auto& blocker_without_cosmetics = make_blocker({ "||example.com/ad^"_string }); + EXPECT(!blocker_without_cosmetics.has_cosmetic_rules()); + EXPECT(blocker_without_cosmetics.cosmetic_style_sheet_for_url(url("https://example.com/"sv), {}, {}).is_empty()); + + auto& blocker_with_cosmetics = make_blocker({ "example.com##.ad-banner"_string }); + EXPECT(blocker_with_cosmetics.has_cosmetic_rules()); } } diff --git a/UI/Android/src/main/cpp/WebContentService.cpp b/UI/Android/src/main/cpp/WebContentService.cpp index a6c2de8e30..e1e67d5860 100644 --- a/UI/Android/src/main/cpp/WebContentService.cpp +++ b/UI/Android/src/main/cpp/WebContentService.cpp @@ -120,7 +120,7 @@ static ErrorOr load_content_blockers() continue; auto pattern = TRY(String::from_utf8(line)); - TRY(patterns.try_append(move(pattern))); + patterns.append(move(pattern)); } auto& content_blocker = Web::ContentBlocker::the();