ladybird/Tests/LibWeb/TestContentBlocker.cpp
Andreas Kling 96bad93bb2 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.
2026-05-24 08:16:46 +02:00

296 lines
12 KiB
C++

/*
* Copyright (c) 2025, Tim Ledbetter <tim.ledbetter@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Array.h>
#include <LibTest/TestCase.h>
#include <LibURL/Parser.h>
#include <LibURL/URL.h>
#include <LibWeb/Loader/ContentBlocker.h>
namespace Web {
static ContentBlocker& make_blocker(Vector<String> rules)
{
auto& blocker = ContentBlocker::the();
MUST(blocker.set_patterns(rules));
blocker.set_filtering_enabled(true);
return blocker;
}
static URL::URL url(StringView string)
{
auto result = URL::Parser::basic_parse(string);
EXPECT(result.has_value());
return result.release_value();
}
TEST_CASE(empty_pattern_list)
{
auto& blocker = make_blocker({});
EXPECT(!blocker.is_filtered(url("https://anything.com"sv)));
EXPECT(!blocker.is_filtered(url("data:text/plain,hi"sv)));
}
TEST_CASE(basic_blocking)
{
Vector<String> rules = {
"||ads.example.com^"_string,
"/banner.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/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<String> rules = {
{ "data:"_string },
{ "||evil.com^"_string }
};
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), source_url, ContentBlocker::ResourceType::Script));
}
TEST_CASE(invalid_filter_bytes_keep_previous_rules)
{
Vector<String> 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<u8, 1> 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<String> rules = {
{ "||example.com^"_string },
{ "##.ad"_string }
};
auto& blocker = make_blocker(move(rules));
blocker.set_filtering_enabled(false);
Vector<String> classes = { "ad"_string };
Vector<String> 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), 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), classes.span(), ids.span()).is_empty());
}
TEST_CASE(fetch_metadata_maps_to_resource_type)
{
using Request = Fetch::Infrastructure::Request;
using ResourceType = ContentBlocker::ResourceType;
auto resource_type = [](Optional<Request::Destination> destination, Optional<Request::InitiatorType> initiator_type, Request::Mode mode = Request::Mode::NoCORS) {
return ContentBlocker::resource_type_from_fetch_metadata(destination, initiator_type, mode);
};
EXPECT(resource_type(Request::Destination::Document, {}) == ResourceType::Document);
EXPECT(resource_type(Request::Destination::Font, Request::InitiatorType::CSS) == ResourceType::Font);
EXPECT(resource_type(Request::Destination::Image, Request::InitiatorType::CSS) == ResourceType::Image);
EXPECT(resource_type(Request::Destination::Style, {}) == ResourceType::Stylesheet);
EXPECT(resource_type(Request::Destination::IFrame, {}) == ResourceType::Subdocument);
EXPECT(resource_type({}, Request::InitiatorType::Fetch) == ResourceType::XMLHttpRequest);
EXPECT(resource_type({}, Request::InitiatorType::Ping) == ResourceType::Ping);
EXPECT(resource_type({}, {}, Request::Mode::WebSocket) == ResourceType::WebSocket);
}
TEST_CASE(resource_type_options)
{
using Request = Fetch::Infrastructure::Request;
Vector<String> 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(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://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::InitiatorType> {},
Request::Mode::Navigate));
EXPECT(blocker.is_filtered(
url("https://example.com/fetch"sv),
source_url,
Optional<Request::Destination> {},
Request::InitiatorType::Fetch,
Request::Mode::CORS));
}
TEST_CASE(file_scheme_fallback)
{
Vector<String> rules = {
{ "secret"_string },
};
auto& blocker = make_blocker(move(rules));
EXPECT(blocker.is_filtered(url("file:///home/user/secret.txt"sv)));
EXPECT(!blocker.is_filtered(url("file:///home/user/public.txt"sv)));
}
TEST_CASE(third_party_option)
{
Vector<String> rules = {
{ "||tracker.example^$third-party"_string },
};
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<String> 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<String> 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<String> 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<String> 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<String> classes = { "generic-ad"_string };
Vector<String> 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<String> 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<String> classes = { "second-ad-class"_string };
Vector<String> ids = { "second-ad-id"_string };
Vector<String> no_classes;
Vector<String> 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());
}
}