Stage 7: ad/tracker blocking via Brave's adblock-rust (C FFI)
ober
1e05cbf099522f59b4ffd928faf9a8e671722faf
--- a/.build.yml +++ b/.build.yml @@ -14,6 +14,11 @@ packages: - uuid-dev - liblz4-dev - zlib1g-dev + # Rust toolchain for the vendored adblock-rust-ffi staticlib (cmake runs + # `cargo build --release`, linked into libjerboa_browser). Cargo.lock pins the + # deps; if bookworm's rustc is ever too old, switch this to a rustup install. + - cargo + - rustc sources: - https://git.sr.ht/~lisp/jerboa-browser # Chez Scheme is vendored in the jerboa repo (vendor/ChezScheme); cloned so CI @@ -60,6 +65,11 @@ tasks: echo "--- native binary ---" tar xzf "$tb" -O jerboa-browser-0.0.1-linux-x86_64/bin/jerboa-browser \ > /tmp/jb && file /tmp/jb + # Stage 7 — adblock-rust engine_match semantics (pure C++, no Qt/network). + # build-tarball.sh only builds the jerboa_browser target, so build the test. + echo "--- adblock ctest ---" + cmake --build qt-webengine/build --target jwb_adblock_test + ctest --test-dir qt-webengine/build -R adblock --output-on-failure - test-gui: | cd jerboa-browser # Offline Qt GUI / snapshot harness (offscreen, headless). Kept hermetic @@ -162,3 +172,15 @@ tasks: make test-polish # Stage all harness snapshots (stage0 + stage1 + stage2) as an artifact. tar czf gui-snapshots.tar.gz test-artifacts + - test-adblock: | + cd jerboa-browser + # Stage 7 ad/tracker blocking: the process-global toggle ABI + the + # toggle-adblock command (C-c b) + echo (offscreen, hermetic). Opening a + # session also builds the engine from the built-in starter list. The + # engine_match semantics are covered by the C++ ctest in the verify task. + export QT_QPA_PLATFORM=offscreen JWB_TEST_NO_NETWORK=1 + export QTWEBENGINE_DISABLE_SANDBOX=1 + export QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" + export JERBOA_HOME="$HOME/jerboa" SCHEME="$(command -v scheme)" + export JERBOA_BROWSER_LIB="$PWD/qt-webengine/build/libjerboa_browser.so" + make test-adblock --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa) SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme LIBDIRS := $(CURDIR)/scheme:$(JERBOA_HOME)/lib -.PHONY: binary test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-gui test-buffers repl clean help +.PHONY: binary test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-gui test-buffers repl clean help .DEFAULT_GOAL := help # Build the self-contained native ./jerboa-browser (Chez + boot + (browser)). @@ -57,6 +57,13 @@ test-polish: QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \ $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-polish-test.ss +# Stage 7 ad/tracker blocking (adblock-rust): the process-global toggle ABI and +# the toggle-adblock command + echo. The engine_match semantics are unit-tested +# in C++ (cd qt-webengine/build && ctest -R adblock). +test-adblock: + QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \ + $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-adblock-test.ss + # Offline Qt GUI / snapshot harness (headless). Writes PNGs to ./test-artifacts. test-gui: QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \ --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ hint-mode. `C-h b` opens a buffer listing every binding. | `f` / `M-g` (or `; f`) | follow hint (this / new buffer) | hint-mode | | `C-c p s` / `C-c p f` / `C-c p k` | save / fill / forget login (RAM only) | passwords | | `C-x r m` / `C-x r b` | bookmark current / open bookmark | bookmarks | +| `C-c b` | toggle ad / tracker blocking | blocking | | `M-x` | run command by name (shows docs) | palette | | `C-h b` | describe all bindings | help | | `C-x C-c` | quit | | @@ -49,11 +50,16 @@ Passwords are held **in RAM only** — never written to env, config, or disk — XOR-masked in the heap with a per-session pad and zeroed immediately after each use. Bookmarks are likewise in-RAM (opt-in encrypted persistence is a later item). +Ad / tracker blocking is **on by default**, powered by Brave's `adblock-rust` +engine (vendored, linked into `libjerboa_browser`) and consulted on every +network request. `C-c b` toggles it; the status line shows `block` while active +and the echo area reports the running count of blocked requests. + ## Build / test ```sh make binary # the self-contained native ./jerboa-browser -make test # (browser) unit suite; also test-{keymap,minibuffer,…,polish} +make test # (browser) unit suite; also test-{keymap,…,polish,adblock} make test-gui # offline Qt snapshot harness (offscreen, headless) ``` --- a/ROADMAP.md +++ b/ROADMAP.md @@ -248,6 +248,42 @@ and add bookmarks + help + docs. lists keys + commands; the new chords resolve. Needs `--disable-gpu` (the bindings page would otherwise FATAL the offscreen GPU surface). +### Stage 7 — Ad / tracker blocking (adblock-rust) ✅ DONE +Real, toggleable ad/tracker blocking with Brave-grade filter parsing — chosen +over a hosts-file approximation for true filter-syntax (ABP/EasyList) parity. +Added after the Stage 0→6 nyxt mandate; same in-process, no-disk posture. +- **Vendored** `qt-webengine/vendor/adblock-rust-ffi` (crate `adblock-ffi`, + `Cargo.lock`-pinned `adblock` 0.3.15). The `JWB_ADBLOCK` cmake option (default + ON) runs `cargo build --release` → `libadblock.a`, linked into + `libjerboa_browser`; macOS links just the `.a`, Linux adds `-lpthread -ldl`. + `lib.h` is plain C, so C++ `#include`s it inside `extern "C"`. +- **C++** (`browser_policy.cpp`): a process-global engine + `std::atomic` toggle + + blocked counter. The engine is a lazily-built singleton (`set_domain_resolver` + once, then `engine_create`) seeded from a built-in ~30-rule `||host^` starter + list. `NetworkInterceptor::interceptRequest` consults it FIRST — reordered + ahead of the capability/network-cap gate — and blocks iff + `did_match_rule && !did_match_exception`. `install_policy` builds it eagerly. +- **ABI** (`jerboa_browser.h`): `jwb_adblock_{set_enabled,is_enabled, + block_count,available}`, `#ifdef JWB_ADBLOCK`-guarded; graceful no-ops (and + `available`→0) when built without it. +- **Scheme**: `(browser)` adds `browser-adblock-{available?,enabled?,set!, + block-count}`; `(browser commands)` adds the `toggle-adblock` command (`C-c b`), + appends `block` to the status modes string while active, and echoes + "Ad blocking on (N blocked)" / "off". +- **Tests**: hermetic C++ `ctest -R adblock` (`tests/adblock_test.cc`, 6 + match-semantics checks: ad host blocked, safe host allowed, tracker blocked, + `@@` exception allows, real `doubleclick.net` blocked, first-party page + allowed — no Qt, no network) + `make test-adblock` (`browser-adblock-test.ss`, + 7 cases: toggle ABI + return value, block-count non-neg, session-open builds + the engine, command registered, `C-c b` flips + echoes, run-by-name toggles). + CI builds the staticlib in `build` and runs both in `verify` + `test-adblock`. +- **Deferred follow-ups**: load a full EasyList/EasyPrivacy file at startup (the + starter list is intentionally tiny to keep the repo hermetic); cosmetic + (element-hiding) filtering via the engine's cosmetic API; a PSL-aware domain + resolver (the current naive one treats the whole host as the registrable + domain — fine for hostname-anchored rules); hide the internal Rust `adblock::*` + symbols the staticlib re-exports. + --- ## Testing doctrine (mirrors jerboa-emacs) --- a/WISHLIST.md +++ b/WISHLIST.md @@ -14,6 +14,9 @@ offline Qt snapshot + functional tests modeled on `~/mine/jerboa-emacs`. - [x] **Hint-mode** — link hinting / follow (`f`, `M-g`, `; f`) via JS overlay; home-row labels, follow this/new buffer, C-g/escape cancels — *Stage 4* — `make test-hint` (10) - [x] **Password manager** — in-process, memory-only (no env, no config/file), masked entry, autofill; secrets XOR-masked in RAM + zeroed after use — *Stage 5* — `(browser passwords)`; `C-c p s/f/k`; `make test-pass` (12) - [x] **Polish** — status modes (`[emacs hint] [i/n] title — url`), echo-area wiring (minibuffer line = echo area), M-x palette shows docs, in-RAM bookmarks (`C-x r m/b`), `describe-bindings` (`C-h b`) help page, README keymap — *Stage 6* — `(browser bookmarks)`; `make test-polish` (14) +- [x] **Ad / tracker blocking** — Brave's `adblock-rust` engine via its C FFI (vendored, linked into `libjerboa_browser`); a process-global engine + toggle consulted first in the network interceptor (block iff matched & not excepted); `C-c b` toggle, `block` status mode, blocked-count echo — *Stage 7* — `(browser)` adblock wrappers + `toggle-adblock`; `make test-adblock` (7) + C++ `ctest -R adblock` (6) Later / maybe: vi keyscheme parity, AppImage packaging, history persistence -(opt-in, encrypted), search-engine prefixes, download manager, ad/JS toggles. +(opt-in, encrypted), search-engine prefixes, download manager, JS toggles; +ad-blocking follow-ups — full EasyList/EasyPrivacy loading, cosmetic +(element-hiding) filtering, a PSL-aware domain resolver. --- a/include/jerboa_browser.h +++ b/include/jerboa_browser.h @@ -244,6 +244,18 @@ JWB_API const char *jwb_window_poll_key(JwbHandle window); * Scheme run-loop exit when the window is dismissed. */ JWB_API int jwb_window_is_visible(JwbHandle window); +/* === Ad / tracker blocking (Stage 7) =================================== + * A single process-global adblock engine (Brave's adblock-rust via its C FFI, + * vendored in qt-webengine/vendor/) filters network sub-resource requests in + * the request interceptor, built lazily from a small built-in starter filter + * list. Network-blocking only; cosmetic filtering is future work. The toggle is + * process-global (default ON). When built with -DJWB_ADBLOCK=OFF these are + * inert: set_enabled is a no-op and the getters return 0. */ +JWB_API int jwb_adblock_set_enabled(int on); /* returns the new state (1/0) */ +JWB_API int jwb_adblock_is_enabled(void); +JWB_API int jwb_adblock_block_count(void); /* requests blocked since start */ +JWB_API int jwb_adblock_available(void); /* 1 if compiled in, else 0 */ + #ifdef __cplusplus } /* extern "C" */ #endif --- a/jerboa-browser.md +++ b/jerboa-browser.md @@ -118,8 +118,11 @@ Qt WebEngine / Chromium Qt application window / browser pane ``` -No Rust layer is needed for the Qt WebEngine backend. Keep the ABI narrow so a -future Servo backend can implement the same logical operations. +The core Qt WebEngine backend needs no Rust. (Stage 7 later adds one *optional* +vendored Rust staticlib — Brave's `adblock-rust` — linked into +`libjerboa_browser` for ad/tracker blocking, gated by the `JWB_ADBLOCK` cmake +option; it is not part of the C ABI. See ROADMAP.md Stage 7.) Keep the ABI +narrow so a future Servo backend can implement the same logical operations. ## Repository layout --- a/qt-webengine/CMakeLists.txt +++ b/qt-webengine/CMakeLists.txt @@ -31,6 +31,32 @@ target_link_libraries(jerboa_browser PRIVATE Qt6::Core Qt6::Widgets Qt6::WebEngineWidgets ) +# --- ad / tracker blocking (Stage 7) --------------------------------------- +# Brave's adblock-rust via its C FFI, vendored under vendor/adblock-rust-ffi. +# cargo builds a staticlib (libadblock.a) that links into the jerboa_browser +# shared lib; the interceptor in browser_policy.cpp calls engine_match(). Turn +# off (-DJWB_ADBLOCK=OFF) to build without a Rust toolchain — blocking then +# compiles out to no-ops. +option(JWB_ADBLOCK "Ad/tracker blocking via vendored adblock-rust (needs cargo)" ON) +if(JWB_ADBLOCK) + set(ADBLOCK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/adblock-rust-ffi) + set(ADBLOCK_LIB ${ADBLOCK_DIR}/target/release/libadblock.a) + find_program(CARGO_EXE cargo REQUIRED) + add_custom_command( + OUTPUT ${ADBLOCK_LIB} + COMMAND ${CARGO_EXE} build --release --manifest-path ${ADBLOCK_DIR}/Cargo.toml + COMMENT "Building vendored adblock-rust staticlib (cargo build --release)" + VERBATIM) + add_custom_target(adblock_rust DEPENDS ${ADBLOCK_LIB}) + add_dependencies(jerboa_browser adblock_rust) + target_compile_definitions(jerboa_browser PRIVATE JWB_ADBLOCK=1) + target_include_directories(jerboa_browser PRIVATE ${ADBLOCK_DIR}/src) + target_link_libraries(jerboa_browser PRIVATE ${ADBLOCK_LIB}) + if(NOT APPLE) + target_link_libraries(jerboa_browser PRIVATE pthread dl) + endif() +endif() + # Smoke-test / demo driver. Grows into the load+eval demo in Ticket 1.2. add_executable(jwb_demo src/main_demo.cpp) target_link_libraries(jwb_demo PRIVATE jerboa_browser) --- a/qt-webengine/src/browser_policy.cpp +++ b/qt-webengine/src/browser_policy.cpp @@ -1,9 +1,14 @@ -// browser_policy.cpp — deny-by-default capability policy on a context's profile. +// browser_policy.cpp — deny-by-default capability policy + ad/tracker blocking. // -// Ticket 1.3. Each capability absent from the context's flags removes the -// corresponding authority: network requests are blocked, downloads cancelled, -// popups refused, local-file and clipboard access disabled. DevTools/remote -// debugging is opt-in elsewhere and never enabled here. +// Ticket 1.3 + Stage 7. Each capability absent from the context's flags removes +// the corresponding authority: network requests are blocked, downloads +// cancelled, popups refused, local-file and clipboard access disabled. +// On top of that, a single process-global adblock engine (Brave's adblock-rust +// via its C FFI, vendored under qt-webengine/vendor/) filters network +// sub-resource requests in the interceptor — built lazily from a small built-in +// starter filter list and toggled at runtime (default on). The adblock layer is +// compiled only when JWB_ADBLOCK is defined; otherwise the jwb_adblock_* entry +// points are inert no-ops and the interceptor does only capability gating. #include "browser_objects.h" #include <QWebEngineDownloadRequest> @@ -14,16 +19,155 @@ #include <QLatin1String> #include <QString> +#include <atomic> #include <cstdio> +#include <cstring> + +#include "jerboa_browser.h" + +#ifdef JWB_ADBLOCK +extern "C" { +#include "lib.h" // adblock-rust-ffi (vendored): engine_create/_match/_destroy +} +#endif namespace { -// When network is not granted, only in-page / local schemes pass; everything -// else is blocked before leaving the process. +#ifdef JWB_ADBLOCK +// --- adblock-rust process-global engine ------------------------------------- +// The QWebEngine request interceptor runs on the UI thread, so the engine is +// effectively single-threaded; the toggle and counter are still atomic so the +// jwb_adblock_* ABI can be poked from the Scheme run-loop without a data race. +std::atomic<bool> g_adblock_enabled{true}; +std::atomic<long> g_adblock_blocked{0}; +C_Engine *g_adblock_engine = nullptr; // built once, on the UI thread + +// A small, uncontroversial starter list of ad/tracker hostnames in ABP syntax. +// Network rules only (cosmetic filtering is future work); `||host^` matches the +// host regardless of resource type / first-party, so the naive resolver below +// is sufficient. Loading a full list (EasyList) at runtime is a planned +// follow-up — see jerboa-browser.md. +const char *kSeedFilters = + "! jerboa-browser built-in starter list\n" + "||doubleclick.net^\n" + "||googlesyndication.com^\n" + "||google-analytics.com^\n" + "||googletagmanager.com^\n" + "||googletagservices.com^\n" + "||googleadservices.com^\n" + "||adservice.google.com^\n" + "||g.doubleclick.net^\n" + "||2mdn.net^\n" + "||amazon-adsystem.com^\n" + "||adnxs.com^\n" + "||adsrvr.org^\n" + "||rubiconproject.com^\n" + "||pubmatic.com^\n" + "||casalemedia.com^\n" + "||criteo.com^\n" + "||criteo.net^\n" + "||taboola.com^\n" + "||outbrain.com^\n" + "||scorecardresearch.com^\n" + "||quantserve.com^\n" + "||moatads.com^\n" + "||doubleverify.com^\n" + "||serving-sys.com^\n" + "||bluekai.com^\n" + "||hotjar.com^\n" + "||mixpanel.com^\n" + "||segment.io^\n" + "||connect.facebook.net^\n" + "||facebook.com/tr\n" + "||ads.yahoo.com^\n" + "||adcolony.com^\n"; + +// adblock-rust requires a domain resolver before any matching. This naive one +// treats the whole hostname as the registrable domain (start=0), which is +// sufficient for the hostname-anchored starter rules; a public-suffix-aware +// resolver is future work. +void adblock_domain_resolver(const char *host, uint32_t *start, uint32_t *end) { + *start = 0; + *end = host ? static_cast<uint32_t>(std::strlen(host)) : 0; +} + +C_Engine *adblock_engine() { + if (!g_adblock_engine) { + static bool resolver_set = false; + if (!resolver_set) { + set_domain_resolver(adblock_domain_resolver); + resolver_set = true; + } + g_adblock_engine = engine_create(kSeedFilters); + } + return g_adblock_engine; +} + +// eTLD+1 approximated by the last two labels (no public-suffix list) — used +// only to decide first-party vs third-party; rule matching uses the resolver. +QString last_two_labels(const QString &host) { + const int dot = host.lastIndexOf(QLatin1Char('.')); + if (dot <= 0) return host; + const int dot2 = host.lastIndexOf(QLatin1Char('.'), dot - 1); + return dot2 < 0 ? host : host.mid(dot2 + 1); +} + +// Map Qt's resource type to the adblock-rust request-type strings. +const char *adblock_resource_type(QWebEngineUrlRequestInfo::ResourceType t) { + using R = QWebEngineUrlRequestInfo; + switch (t) { + case R::ResourceTypeMainFrame: return "document"; + case R::ResourceTypeSubFrame: return "sub_frame"; + case R::ResourceTypeStylesheet: return "stylesheet"; + case R::ResourceTypeScript: return "script"; + case R::ResourceTypeImage: return "image"; + case R::ResourceTypeFontResource: return "font"; + case R::ResourceTypeObject: return "object"; + case R::ResourceTypeMedia: return "media"; + case R::ResourceTypeXhr: return "xmlhttprequest"; + case R::ResourceTypePing: return "ping"; + case R::ResourceTypeFavicon: return "image"; + case R::ResourceTypeWebSocket: return "websocket"; + default: return "other"; + } +} + +bool adblock_should_block(QWebEngineUrlRequestInfo &info) { + C_Engine *e = adblock_engine(); + if (!e) return false; + const QByteArray url8 = info.requestUrl().toString().toUtf8(); + const QString host = info.requestUrl().host(); + const QString fp = info.firstPartyUrl().host(); + const QByteArray host8 = host.toUtf8(); + const QByteArray fp8 = fp.toUtf8(); + const bool third_party = + !fp.isEmpty() && last_two_labels(host).compare( + last_two_labels(fp), Qt::CaseInsensitive) != 0; + bool matched = false, exception = false, important = false; + char *redirect = nullptr; + engine_match(e, url8.constData(), host8.constData(), + fp8.isEmpty() ? host8.constData() : fp8.constData(), third_party, + adblock_resource_type(info.resourceType()), &matched, &exception, + &important, &redirect); + if (redirect) c_char_buffer_destroy(redirect); + return matched && !exception; +} +#endif // JWB_ADBLOCK + +// The interceptor applies adblock first (when enabled), then the network +// capability gate: with network granted only adblock filters; without it, only +// in-page / local schemes pass and everything else is blocked in-process. class NetworkInterceptor : public QWebEngineUrlRequestInterceptor { public: explicit NetworkInterceptor(bool allow_network) : allow_(allow_network) {} void interceptRequest(QWebEngineUrlRequestInfo &info) override { +#ifdef JWB_ADBLOCK + if (g_adblock_enabled.load() && adblock_should_block(info)) { + g_adblock_blocked.fetch_add(1); + info.block(true); + return; + } +#endif if (allow_) return; const QString scheme = info.requestUrl().scheme(); if (scheme == QLatin1String("about") || scheme == QLatin1String("data") || @@ -47,6 +191,10 @@ void install_policy(Context *c) { const bool filesystem = c->caps & JWB_CAP_FILESYSTEM; const bool clipboard = c->caps & JWB_CAP_CLIPBOARD; +#ifdef JWB_ADBLOCK + adblock_engine(); // build the engine eagerly so the first request is cheap +#endif + auto *intc = new NetworkInterceptor(network); c->interceptor = intc; c->profile->setUrlRequestInterceptor(intc); @@ -73,3 +221,40 @@ void install_policy(Context *c) { } } // namespace jwb + +// --- adblock toggle ABI (jerboa_browser.h) ---------------------------------- +// Declared extern "C" + JWB_API in the header; defined here next to the engine. +// Inert no-ops when JWB_ADBLOCK is not defined. +int jwb_adblock_set_enabled(int on) { +#ifdef JWB_ADBLOCK + g_adblock_enabled.store(on != 0); + return g_adblock_enabled.load() ? 1 : 0; +#else + (void)on; + return 0; +#endif +} + +int jwb_adblock_is_enabled(void) { +#ifdef JWB_ADBLOCK + return g_adblock_enabled.load() ? 1 : 0; +#else + return 0; +#endif +} + +int jwb_adblock_block_count(void) { +#ifdef JWB_ADBLOCK + return static_cast<int>(g_adblock_blocked.load()); +#else + return 0; +#endif +} + +int jwb_adblock_available(void) { +#ifdef JWB_ADBLOCK + return 1; +#else + return 0; +#endif +} --- a/qt-webengine/tests/CMakeLists.txt +++ b/qt-webengine/tests/CMakeLists.txt @@ -14,3 +14,16 @@ target_include_directories(jwb_abi_test PRIVATE ) add_test(NAME jwb_abi_test COMMAND jwb_abi_test) + +# Stage 7 — adblock-rust FFI match semantics. Pure C++; links the vendored +# staticlib directly (no Qt). Only built when JWB_ADBLOCK is enabled. +if(JWB_ADBLOCK) + add_executable(jwb_adblock_test adblock_test.cc) + add_dependencies(jwb_adblock_test adblock_rust) + target_include_directories(jwb_adblock_test PRIVATE ${ADBLOCK_DIR}/src) + target_link_libraries(jwb_adblock_test PRIVATE ${ADBLOCK_LIB}) + if(NOT APPLE) + target_link_libraries(jwb_adblock_test PRIVATE pthread dl) + endif() + add_test(NAME jwb_adblock_test COMMAND jwb_adblock_test) +endif() new file mode 100644 --- /dev/null +++ b/qt-webengine/tests/adblock_test.cc @@ -0,0 +1,65 @@ +// adblock_test.cc — hermetic unit test for the vendored adblock-rust FFI. +// +// Links libadblock.a directly (no Qt, no network, no QApplication) and exercises +// the exact match semantics browser_policy.cpp relies on: a request is blocked +// iff did_match_rule && !did_match_exception. Built only when JWB_ADBLOCK is on. +extern "C" { +#include "lib.h" +} +#include <cstdio> +#include <cstring> + +static int failures = 0; + +// Naive resolver, matching browser_policy.cpp: whole host == domain. +static void resolver(const char *host, uint32_t *s, uint32_t *e) { + *s = 0; + *e = host ? static_cast<uint32_t>(std::strlen(host)) : 0; +} + +static void check(C_Engine *e, const char *url, const char *host, + bool third_party, bool want_block, const char *desc) { + bool matched = false, exception = false, important = false; + char *redirect = nullptr; + engine_match(e, url, host, "example.com", third_party, "image", &matched, + &exception, &important, &redirect); + if (redirect) c_char_buffer_destroy(redirect); + const bool blocked = matched && !exception; + if (blocked != want_block) { + std::printf("FAIL: %s (%s) -> blocked=%d want=%d\n", desc, url, blocked, + want_block); + ++failures; + } else { + std::printf("ok: %s\n", desc); + } +} + +int main() { + if (!set_domain_resolver(resolver)) { + std::printf("FAIL: set_domain_resolver returned false\n"); + return 1; + } + C_Engine *e = engine_create("||ads.example^\n" + "||tracker.example^\n" + "@@||tracker.example/allowed^\n" + "||doubleclick.net^\n"); + if (!e) { + std::printf("FAIL: engine_create returned null\n"); + return 1; + } + check(e, "http://ads.example/banner.gif", "ads.example", true, true, + "ad host blocked"); + check(e, "http://safe.example/pic.gif", "safe.example", true, false, + "safe host allowed"); + check(e, "http://tracker.example/x.js", "tracker.example", true, true, + "tracker blocked"); + check(e, "http://tracker.example/allowed/x.js", "tracker.example", true, + false, "exception rule allows"); + check(e, "https://2mdn.doubleclick.net/ad", "2mdn.doubleclick.net", true, + true, "real ad host (doubleclick) blocked"); + check(e, "https://example.com/index.html", "example.com", false, false, + "first-party page allowed"); + engine_destroy(e); + std::printf("%s (%d failure(s))\n", failures ? "FAILED" : "PASSED", failures); + return failures ? 1 : 0; +} new file mode 100644 --- /dev/null +++ b/scheme/browser-adblock-test.ss @@ -0,0 +1,129 @@ +#!chezscheme +;;; browser-adblock-test.ss — Stage 7 ad/tracker blocking (adblock-rust). +;;; +;;; The match engine itself (engine_create / engine_match semantics: a request +;;; is blocked iff did_match_rule && !did_match_exception) is unit-tested in C++ +;;; at qt-webengine/tests/adblock_test.cc — run with `ctest -R adblock`. +;;; +;;; This file covers the Scheme surface: the process-global toggle ABI wrappers +;;; (browser-adblock-*) and the `toggle-adblock` command + echo wiring + its +;;; C-c b binding. Hermetic: no network, offscreen. Opening a session also +;;; exercises engine_create with the built-in starter list (install_policy +;;; builds the engine eagerly), so a broken seed list would surface here too. +;;; +;;; Run: QT_QPA_PLATFORM=offscreen make test-adblock +;;; Exits 0 on success, 1 on any failed case. + +(import (chezscheme) (browser) (browser buffers) + (browser keymap) (browser minibuffer) (browser commands)) + +;;; ─── tiny test framework (shared shape with the other test files) ───────── +(define *pass* 0) +(define *fail* 0) +(define *test-name* "(none)") + +(define-syntax test-group + (syntax-rules () + [(_ name body ...) + (begin (display "\n=== ") (display name) (display " ===\n") + (flush-output-port (current-output-port)) body ...)])) + +(define (run-test-case name thunk) + (set! *test-name* name) + (let ((ok (guard (e (#t + (set! *fail* (+ *fail* 1)) + (display " FAIL: ") (display name) (newline) + (display " error: ") + (display (if (message-condition? e) (condition-message e) + (format "~s" e))) + (newline) (flush-output-port (current-output-port)) #f)) + (thunk) #t))) + (when ok + (set! *pass* (+ *pass* 1)) + (display " pass: ") (display name) (newline) + (flush-output-port (current-output-port))))) + +(define-syntax test-case + (syntax-rules () [(_ name body ...) (run-test-case name (lambda () body ...))])) + +(define-syntax check + (syntax-rules (=>) + [(_ expr => expected) + (let ((got expr) (exp expected)) + (unless (equal? got exp) + (error 'check (format "~a: expected ~s, got ~s" *test-name* exp got))))])) + +(unless (getenv "QT_QPA_PLATFORM") + (error 'setup "run under QT_QPA_PLATFORM=offscreen (use: make test-adblock)")) + +;; Is `needle` a substring of `hay`? +(define (str-has? hay needle) + (let ((hn (string-length hay)) (nn (string-length needle))) + (let loop ((i 0)) + (cond ((> (+ i nn) hn) #f) + ((string=? (substring hay i (+ i nn)) needle) #t) + (else (loop (+ i 1))))))) + +;; A hermetic page: no-network session, window shown + sized + pumped, controller. +(define-syntax with-page + (syntax-rules () + [(_ (app s v) url body ...) + (let* ((s (open-browser-session (browser-capabilities))) ; no network + (win (session-window s))) + (browser-window-resize win 800 600) + (browser-window-show win) + (session-open-buffer s url) + (browser-pump 300) + (let* ((app (make-browser-app s)) + (v (buffer-view (session-current-buffer s)))) + body ... + (close-browser-session! s)))])) + +(define blank-page "data:text/html,<html><body>blank</body></html>") + +;;; ─── toggle ABI wrappers (process-global) ─────────────────────────────────── +(test-group "adblock toggle ABI" + (test-case "compiled in (JWB_ADBLOCK); default enabled" + (check (browser-adblock-available?) => #t) + (browser-adblock-set! #t) ; normalize to a known state + (check (browser-adblock-enabled?) => #t)) + (test-case "set! flips the live state and returns the new state" + (check (browser-adblock-set! #f) => #f) + (check (browser-adblock-enabled?) => #f) + (check (browser-adblock-set! #t) => #t) + (check (browser-adblock-enabled?) => #t)) + (test-case "block-count is a non-negative integer" + (let ((n (browser-adblock-block-count))) + (check (and (integer? n) (>= n 0)) => #t)))) + +;;; ─── opening a session builds the engine from the starter list ────────────── +(test-group "engine builds with the built-in starter list" + (test-case "a no-network session opens cleanly (install_policy → engine_create)" + (with-page (app s v) blank-page + (check (and (browser-view? v) #t) => #t) + (check (browser-adblock-enabled?) => #t)))) ; still on after a context + +;;; ─── toggle-adblock command + echo + C-c b binding ────────────────────────── +(test-group "toggle-adblock command" + (test-case "registered + documented" + (check (and (command-ref 'toggle-adblock) #t) => #t)) + (test-case "C-c b flips blocking off then on, echoing each state" + (with-page (app s v) blank-page + (browser-adblock-set! #t) ; known start: ON + (app-feed-token! app "C-c") (app-feed-token! app "b") + (check (browser-adblock-enabled?) => #f) + (check (str-has? (app-echo app) "off") => #t) + (app-feed-token! app "C-c") (app-feed-token! app "b") + (check (browser-adblock-enabled?) => #t) + (check (str-has? (app-echo app) "Ad blocking on") => #t))) + (test-case "run-command-by-name toggles too" + (with-page (app s v) blank-page + (browser-adblock-set! #t) + (run-command-by-name app "toggle-adblock") + (check (browser-adblock-enabled?) => #f) + (browser-adblock-set! #t)))) ; restore ON + +(newline) +(display "browser-adblock-test: ") (display *pass*) (display " passed, ") +(display *fail*) (display " failed") (newline) +(exit (if (zero? *fail*) 0 1)) --- a/scheme/browser.ss +++ b/scheme/browser.ss @@ -65,6 +65,11 @@ browser-clear-find browser-clipboard-set! browser-clipboard + ;; ad / tracker blocking (Stage 7) + browser-adblock-available? + browser-adblock-enabled? + browser-adblock-set! + browser-adblock-block-count ;; window / buffer chrome browser-window-open browser-window-close @@ -189,6 +194,11 @@ (define-c-lambda %find (uint64 char-string int uint32) int "jwb_view_find") (define-c-lambda %clip-set (char-string) int "jwb_clipboard_set") (define-c-lambda %clip-get () char-string "jwb_clipboard_get") + ;; ad / tracker blocking (browser_policy.cpp): process-global engine + toggle + (define-c-lambda %adblock-set (int) int "jwb_adblock_set_enabled") + (define-c-lambda %adblock-on? () int "jwb_adblock_is_enabled") + (define-c-lambda %adblock-count () int "jwb_adblock_block_count") + (define-c-lambda %adblock-avail () int "jwb_adblock_available") ;; window / buffer chrome (browser_window.cpp) (define-c-lambda %win-open () uint64 "jwb_window_open") (define-c-lambda %win-close (uint64) int "jwb_window_close") @@ -421,6 +431,14 @@ (err* (string-append "clipboard-set: " (%last-error))))) (def (browser-clipboard) (%clip-get)) + ;; Ad / tracker blocking. Process-global (not view-scoped): one engine + one + ;; toggle for the whole browser, applied in the request interceptor. + ;; browser-adblock-available? is #f when built with -DJWB_ADBLOCK=OFF. + (def (browser-adblock-available?) (= 1 (%adblock-avail))) + (def (browser-adblock-enabled?) (= 1 (%adblock-on?))) + (def (browser-adblock-set! on) (= 1 (%adblock-set (if on 1 0)))) + (def (browser-adblock-block-count) (%adblock-count)) + ;; --- window / buffer chrome (browser_window.cpp) ----------------------- ;; A browser-window is the nyxt-style single window hosting many views; the ;; (browser buffers) module layers the buffer list/switch logic on top. --- a/scheme/browser/commands.ss +++ b/scheme/browser/commands.ss @@ -97,7 +97,10 @@ ;; Active minor modes, nyxt-style, for the status line: the base keyscheme is ;; always on; hint-mode shows while hinting. (Shown as "[emacs]" / "[emacs hint]".) (def (app-modes-string app) - (string-append "emacs" (if (app-hints app) " hint" ""))) + (string-append "emacs" + (if (app-hints app) " hint" "") + (if (and (browser-adblock-available?) (browser-adblock-enabled?)) + " block" ""))) ;; Reflect controller state into the window chrome. The minibuffer line doubles ;; as the echo area (as in emacs): while a prompt is open it shows the prompt; @@ -567,6 +570,7 @@ ("C-s" . find-in-page) ("C-M-s" . find-next) ("C-M-r" . find-previous) + ("C-c b" . toggle-adblock) ("C-c u" . copy-url) ("C-c t" . copy-title) ;; hint-mode (Stage 4) — nyxt link hinting / follow @@ -674,6 +678,19 @@ (register-command! 'copy-title "Copy the current buffer's title to the clipboard." (lambda (app) (copy-current! app "title" browser-title))) + ;; --- ad / tracker blocking (Stage 7) ------------------------------------ + (register-command! 'toggle-adblock "Toggle ad/tracker blocking (process-global; default on)." + (lambda (app) + (if (browser-adblock-available?) + (let ((on (browser-adblock-set! (not (browser-adblock-enabled?))))) + (app-echo-set! app + (if on + (string-append "Ad blocking on (" + (number->string (browser-adblock-block-count)) + " blocked)") + "Ad blocking off"))) + (app-echo-set! app "Ad blocking not available in this build")))) + ;; --- hint-mode commands ------------------------------------------------- (register-command! 'follow-hint "Hint links/controls and follow the chosen one." (lambda (app) (start-hint-mode! app #f)))