diff --git a/CHANGELOG.md b/CHANGELOG.md index aef6052684752..3da76ea34574a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ Releases of this community fork. The upstream uBlock Origin changelog follows further below. +## Unreleased + +### Honest filter compilation + +Stock lists (build time): +- Filters that cannot become DNR rules are now counted and reported instead of being dropped silently, with the exceptions listed under *Not yet done* below. Before, the build log said `Unsupported: 0` for every list. + - The build log shows `Unsupported: N` for each list with a count per reason code, and a total per reason for all rulesets at the end. + - `ruleset-details.json` adds `rules.rejectedReasons` and `filters.converted`. + - Source filters are counted, not the rule entries they compile into. + - With the list snapshot of 23 September 2026 (built on 24 September), 1,213 filters in 32 of the 55 lists are reported. Most are `$redirect-rule` (530) and entity or regex hostnames in `domain=`, `to=` or `denyallow=` (337). + - About 1,155 of them are valid filters that DNR cannot express. The other 58 are invalid filters that uBlock Origin rejects too; 51 of those are cosmetic or scriptlet filters with syntax errors. +- `tools/validate-mv3.mjs` rejects a package when the reported counts do not match it: rule counts against the packaged rulesets, per-reason counts against the total, and the `Unsupported` lines of `log.txt`. In development builds it also compares them with the compiler output. +- The dashboard tooltip of each stock list counts the filters actually converted. For uBlock filters it drops from 10,498 to 7,847: the old number counted a filter once per resource type and included filters that never become DNR rules. A second line gives the number of rejected filters, translated into the ten maintained languages. +- `##^responseheader()` filters remove the header from responses of the hostnames they name. They used to act on navigations started from those sites instead. + - They apply to all resource types, as in uBlock Origin, and honour exceptions within the same list. + - A filter whose only hostnames are entities (`example.*`) or regexes is reported instead of becoming a rule for every site. +- `removeparam=~name` is reported as unsupported instead of becoming a rule that never matches. Regex values written as `/re/i` or with the legacy `|` prefix are reported as regex. +- Every built-in redirect resource is packaged and web-accessible, so `redirect=` filters in imported lists and personal filters can use all of them. + - `click2load.html` cannot work through DNR. Both compilers reject it, and `rulesets/redirect-resources.json` lists it as unavailable. + - The build fails when a resource file is missing. + +Imported lists and personal filters (runtime compiler): +- `$requestheader` is rejected. It used to compile into an unconditional block, or an unconditional allow for `@@` filters. +- `$empty` and `$mp4` redirect to the `empty` and `noop-1s.mp4` resources, as in stock lists, instead of blocking every request type. Redirect exceptions (`@@…$redirect`, `@@…$empty`, `@@…$mp4`) are rejected. +- A bare `$removeparam` (or `$removeparam=|`) removes the whole query. It used to remove nothing. +- `@@…$uritransform=…` is rejected. It used to compile into a real redirect, and exceptions skip the trusted-source check. +- `$permissions` with several policies separated by `|` joins them with `, `. The `|` used to stay in the header value, which made the header invalid. +- `important` is no longer dropped. On `removeparam` and `uritransform` it gives priority 2, which stays below every block; on `csp` and `permissions` it gives priority 31, as in stock lists. +- `||host^$to=x` requires both the hostname pattern and `to=`. It used to block every request to `x`. +- `redirect=click2load.html` is rejected, as in stock lists. +- Imported lists compiled by an earlier version are recompiled with these rules (compiled-cache revision 4) instead of keeping their old output. + +Both compilers: +- A `$removeparam` filter with `domain=` plus a hostname pattern or `to=` no longer strips parameters on every page load of the `domain=` sites. Its page-load rule keeps only the hostnames in both lists, and is dropped when none remain. Two over-broad rules in uBlock filters are gone. + +Not yet done: +- Negated entity or regex hostnames in stock filters (`domain=~example.*`, and likewise in `to=` and `denyallow=`) are still dropped silently, with no warning and no count. The rule then also applies to the sites it should exclude. 187 filters in the list snapshot are affected. +- Entity hostnames removed from partly converted stock filters are only logged as warnings, and only for non-regex static rules. +- There is no per-reason view of rejected filters in the dashboard. + +### Documentation and tests + +- New [research into lifting MV3 limits](docs/MV3-LIMITS-RESEARCH-2026-09-24.md) (Vietnamese). It covers what cannot be done in Chrome, the Standard / Experimental / Firefox tiers, measurements on Chrome 153 and an 11-step roadmap. +- Corrected documentation: + - `chrome.dns` is granted on Chrome Stable 153 with `--allowlisted-extension-id` to an extension whose manifest declares `dns`, but returns one IP address and never a CNAME chain. The Experimental package does not declare `dns`. + - Large-media blocking can be expressed with two DNR `Content-Length` rules, at an approximate threshold. The probe blocked from 50,000 bytes; uBlock Origin's default is 51,200 bytes and can be changed, and an exact threshold needs more patterns (not tried). Only responses that carry the header are covered, and the size is the compressed size. + - Remote fonts and `report-uri` CSP reports can be blocked by DNR; they are missing features, not browser limits. + - The `chrome.debugger` route misses responses built by a page's service worker and the first document of a new tab, prompts for Local Network Access on local iframes, and needs auto-attach for subresources of cross-site iframes. +- New tests: `tools/test-stock-compiler-honesty.mjs` and `tools/test-runtime-filter-options.mjs`. + ## 1.2.0 ### Automatic updates diff --git a/docs/EXPERIMENTAL-WEBREQUEST.md b/docs/EXPERIMENTAL-WEBREQUEST.md index 3a3a1d1a2f97b..3e66e3f612711 100644 --- a/docs/EXPERIMENTAL-WEBREQUEST.md +++ b/docs/EXPERIMENTAL-WEBREQUEST.md @@ -2,7 +2,7 @@ This optional Windows/Chromium package adds **synchronous network blocking for saved dynamic-firewall rules**. It uses the same firewall editor, hostname precedence, block/allow/noop rules and local Public Suffix List as the standard package. DNR remains active. The additional listener can evaluate a newly visited domain without waiting for DNR's learned-domain update. -This is an implemented firewall supplement, **not a port of the complete uBlock Origin network engine or an unlimited MV3 mode**. Imported, personal and packaged static network filters still use the existing DNR compiler. DNR rule and regex limits, the firewall editor's limits, response-body restrictions and unsupported inline-script filtering still apply. Compare against [the full uBO compatibility matrix](FEATURE-MATRIX.md). +This is an implemented firewall supplement, **not a port of the complete uBlock Origin network engine or an unlimited MV3 mode**. Imported, personal and packaged static network filters still use the existing DNR compilers, which report most of the filters they cannot convert with a reason code. DNR rule and regex limits, the firewall editor's limits, response-body restrictions and unsupported inline-script filtering still apply. Compare against [the full uBO compatibility matrix](FEATURE-MATRIX.md). Which of these limits this package could lift, and at what cost, is analysed in the [MV3 limits research of 24 September 2026](MV3-LIMITS-RESEARCH-2026-09-24.md) (Vietnamese).
Actual Chrome diagnostics and native blocking evidence @@ -86,8 +86,19 @@ The capability probe on **Google Chrome 152.0.7977.76** compared the same tiny t Chrome's supported MV3 `webRequestBlocking` route remains policy-installed extensions. The allowlist route is an experimental startup switch; it does not turn the extension into a policy installation. In particular, Chromium checks policy location separately for Promise-based blocking responses. [Chrome webRequest](https://developer.chrome.com/docs/extensions/reference/api/webRequest#permissions), [Chromium 152 implementation](https://github.com/chromium/chromium/blob/152.0.7977.76/extensions/renderer/api/web_request_natives.cc#L83). +**Re-check on Google Chrome 153.0.8010.53, 24 September 2026.** Small test extensions were run in fresh temporary profiles, which were deleted afterwards: + +- The switch still grants `webRequestBlocking` to an unpacked MV3 extension. It also granted `dns` to a test extension whose manifest declares it; this package does not declare `dns`. Without the switch, neither is granted. Cancel, redirects (including `main_frame`), and adding or removing response headers took effect. +- Chromium implements the switch as a test hook, [`IsAllowlistedForTest`](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/common/features/simple_feature.cc#L735-L743). It skips every allowlist and install-location check for that ID, not only the one for `webRequestBlocking`. The switch is on the [list of flags](https://github.com/chromium/chromium/blob/153.0.8010.53/chrome/browser/ui/startup/bad_flags_prompt.cc#L119-L121) that show the unsupported-flag warning bar, and Chrome can remove it in any release. +- The extension is still not policy-installed. [Promise responses are ignored](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/renderer/api/web_request_natives.cc#L92-L102): requests whose listener returned a Promise (immediate or delayed cancel, `async` function, redirect) all reached the server, while the same decisions returned synchronously took effect. +- `chrome.dns.resolve()` returns one IP address and never a CNAME chain, so the grant does not enable CNAME uncloaking. +- DNR block and redirect decisions still win over the listener. DNR rules conditioned on response headers act at `onHeadersReceived`, after the request was sent. +- Relaunching the same dedicated profile with the switch across browser restarts, and a cold browser start, were not re-tested. + A full native uBO engine would additionally need one coherent compiled corpus for every enabled stock/imported/personal source, cross-source exceptions and `$badfilter`, request modifiers, startup recovery and engine-switch rollback. The [r58Playz/uBlock-mv3 project](https://github.com/r58Playz/uBlock-mv3#how-it-works) demonstrates that broader architecture; this package does not silently adopt its startup cancellation/reload behavior. +The research measured what such an engine would cost here, on one machine (AMD Ryzen 5 5600H, 8 GB RAM) and one Chrome build. Restoring uBO's `static-net-filtering.js` from an IndexedDB snapshot took 60–75 ms per real worker start with the default lists (138,922 filters) and 75–95 ms with 23 lists (203,598 filters). With realistic pauses between page loads (5 or 35 seconds), up to 44 of a test page's roughly 150 requests, always including its `main_frame` request, passed the listener before the engine was ready; only DNR filtered them. Building the engine synchronously at the worker's top level made Chrome hold the requests instead (0 of 32 passed) at a delay of 100–115 ms, measured with the default lists only. Matching took 30–150 µs per request over the first few hundred requests after a start and 6–8 µs once warm; worker memory grew by 9–16 MiB. DNR stays the always-on baseline even so: Chrome can be started without the switch, user state such as filtering modes and enabled lists is still read asynchronously, and a cold browser start was not measured. + ## Validation of the integrated package — 6 September 2026 The release pipeline passed with **Node 22.22.0 / npm 11.19.1**: `npm ci`, **43 test programs**, lint, standard and experimental Chromium builds, both release validators and verification of every ZIP entry. The Windows PowerShell 5.1 launcher dry-run also passed. An existing development dependency advisory was resolved by updating `@humanfs/node` to 0.16.8 and its required dependencies; `npm audit` reports zero known vulnerabilities. [Upstream advisory](https://github.com/advisories/GHSA-p498-v437-472g). diff --git a/docs/FEATURE-MATRIX.md b/docs/FEATURE-MATRIX.md index b4c7a76c6730f..e5fb28cf55a8f 100644 --- a/docs/FEATURE-MATRIX.md +++ b/docs/FEATURE-MATRIX.md @@ -6,12 +6,14 @@ Ký hiệu: **Có** = bản hiện tại có đường triển khai hữu ích; Gói [Experimental WebRequest](EXPERIMENTAL-WEBREQUEST.md) hiện có **lớp bổ sung chặn firewall đồng bộ**, với launcher/profile riêng và kiểm tra quyền thực cấp. Nó dùng cùng quy tắc firewall, giữ DNR/Off/allow/noop và không tăng quota hay khôi phục toàn bộ engine static uBO. Bảng dưới mô tả bản DNR tiêu chuẩn trừ khi ghi rõ khác. +Giới hạn nào gỡ được, bằng cách nào và giá bao nhiêu được phân tích trong [nghiên cứu ngày 24/9/2026](MV3-LIMITS-RESEARCH-2026-09-24.md): bảng 41 giới hạn, những điều không thể làm trong Chrome, ba tầng Standard / Experimental / Firefox và lộ trình 11 bước. Nghiên cứu đó dùng Chrome 153 và thay thế các nhận định cũ về `chrome.dns`, chặn media lớn, remote font và CSP report trong bảng này. + Theo [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest), static ruleset được đóng gói và có ngân sách riêng với dynamic/session. Từ Chrome 120/121, giới hạn **số rule** dynamic và session được tách, nhưng [Chromium CL ngày 2023-10-18](https://chromium.googlesource.com/chromium/src/+/eab7fc99e59b69e929d02e43bdcf8bbd75333869%5E%21/) xác nhận quota **regex dynamic + session vẫn dùng chung một pool tối đa 1.000**; enabled static rulesets có aggregate pool tối đa 1.000 regex riêng. Con số cụ thể thay đổi theo browser/version, nên đây là mô hình budget chứ không phải bảo đảm mọi máy có cùng capacity. | Capability | uBO MV2 | Power Edition MV3 | Tầng tùy chọn tương lai | Ghi chú trung thực | | --- | --- | --- | --- | --- | -| Static network blocking | Có | **Có**, packaged static DNR | Không cần | Chrome hiện cho khai báo tối đa 100 static ruleset, bật 50 và bảo đảm tối thiểu 30.000 static rules trên tập đang bật; phần vượt mức phụ thuộc `getAvailableStaticRuleCount()`. | -| Custom/imported network lists | Có | **Có**, dynamic DNR trong subset hỗ trợ | Có thể bổ sung compiler native | Dynamic/session có rule-count budget riêng nhưng chia sẻ regex pool; filter không biểu diễn được phải được báo, không cắt im lặng. | +| Static network blocking | Có | **Có**, packaged static DNR | Không cần | Chrome hiện cho khai báo tối đa 100 static ruleset, bật 50 và bảo đảm tối thiểu 30.000 static rules trên tập đang bật; phần vượt mức phụ thuộc `getAvailableStaticRuleCount()`. Filter stock không chuyển được sang DNR được đếm theo mã lý do trong `log.txt` và `ruleset-details.json` (`rules.rejected`, `rules.rejectedReasons`), và validator đối chiếu các số đó với gói. Bản chụp list ngày 23/9/2026 (build ngày 24/9): 1.213 filter trong 32/55 list, nhiều nhất là `$redirect-rule` (530) và entity/regex domain (337). Khoảng 1.155 trong số đó là filter DNR không biểu diễn được; 58 là filter không hợp lệ mà uBO cổ điển cũng từ chối (51 là cosmetic/scriptlet). Trước đó build ghi `Unsupported: 0` cho mọi list. | +| Custom/imported network lists | Có | **Có**, dynamic DNR trong subset hỗ trợ | Có thể bổ sung compiler native | Dynamic/session có rule-count budget riêng nhưng chia sẻ regex pool; filter không biểu diễn được sẽ được báo kèm mã lý do và số dòng, không cắt im lặng. Compiler runtime từ chối `$requestheader`, `@@…$uritransform` và ngoại lệ `@@…$redirect`/`$empty`/`$mp4` thay vì sinh rule rộng hơn; `$empty`/`$mp4` và bare `$removeparam` biên dịch như stock. Runtime vẫn từ chối nhiều filter hơn stock (hai compiler khác nhau). | | Per-site filtering mode | Có | **Có** | Không cần | Persist setting và sinh rule theo namespace. | | Dynamic firewall matrix | Có, quyết định runtime | **Một phần**, native network cells với block/allow/noop, bản tạm/lâu dài và [thử bản nháp](GITHUB-UPGRADES-2026-09-06.md) | Managed adapter có thể mở rộng | Chrome 145+ dùng topDomains; party theo trang cấp cao nhất và PSL. Trang mới có thể cần cập nhật bất đồng bộ. Chưa có full popup matrix, inline-script hoặc main-frame firewall. [Chi tiết](MV3-PARITY-IMPLEMENTATION-2026-09-06.md). | | Cosmetic filtering | Có | **Có** | Không cần | CSS/content script đăng ký theo site/ruleset. | @@ -19,12 +21,13 @@ Theo [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api | Scriptlets | Có | **Một phần** | Không tải scriptlet qua companion | Chỉ scriptlet đóng gói/allowlist; cấm remote executable code. | | Element picker/zapper | Có | **Có** | Không cần | Filter tạo ra được lưu cục bộ. | | Strict/popup blocking | Có | **Có/Một phần** | Có thể bổ sung policy | Smart policy vẫn xử lý opener/target/gesture. Observer thực thi corpus stock `$popup` đóng gói và subset `$popup`/`$popunder` của sandbox/imported: URL/regex đã kiểm tra cùng include/exclude request, initiator và top domains. Stock DNR export chưa giữ kind `$popunder`, nên metadata ghi `omitted` thay vì giả lập. Condition như `domainType`, method, resource type hoặc response header được giữ ở typed route `popup-compiler-required` với status `deferred`; deferred allow còn tạo guard superset chỉ có quyền buộc fail-open, không được tự allow/block. Thiếu context hoặc hết work budget cũng phải fail open với compiled filter; khi mọi ngoại lệ đã bị loại trừ, Smart/Strict vẫn quyết định. | -| Redirect resource | Có | **Một phần** | Không cần | Chỉ redirect tới resource đóng gói/được manifest cho phép. | -| Request/response header rules | Có | **Một phần** | Managed mode có thể mở rộng | DNR `modifyHeaders` không tương đương mọi thao tác `webRequestBlocking`. | +| Redirect resource | Có | **Một phần** | Không cần | Chỉ redirect tới resource đóng gói/được manifest cho phép. Mọi resource có sẵn của uBO đều được đóng gói và web-accessible, trừ `click2load.html` (cần trang click-to-load của uBO); cả hai compiler từ chối resource đó. Không có `$redirect-rule`: DNR không biểu diễn được “chỉ redirect khi request vốn bị chặn”. | +| Request/response header rules | Có | **Một phần** | Managed mode có thể mở rộng | DNR `modifyHeaders` không tương đương mọi thao tác `webRequestBlocking`. `##^responseheader()` stock áp cho response của chính hostname trong filter, mọi loại tài nguyên, và tôn trọng ngoại lệ; ngoại lệ chỉ có hiệu lực trong cùng list. List nhập thêm và bộ lọc cá nhân chưa hỗ trợ `##^responseheader()`. | | Full live request logger | Có | **Một phần**, logger hợp nhất opt-in | **R&D** qua managed/native diagnostics | Quan sát network với quyền tùy chọn, native DNR, CSS/DOM và chẩn đoán scriptlet; 512 bản ghi, có search/export. Không thấy mọi request browser hoặc bảo đảm đầy đủ hiệu ứng MAIN world. | -| Response body/HTML rewriting | Có trên engine hỗ trợ | **Không** | **R&D** qua debugger/Fetch theo tab hoặc local proxy | DNR và managed `webRequestBlocking` không cung cấp body rewrite. Debugger có Fetch nhưng cần quyền không thể xin tùy chọn, quản lý request tạm dừng và target riêng. [Phân tích API](MV3-PARITY-IMPLEMENTATION-2026-09-06.md). | -| CNAME uncloaking/DNS resolution | Có trên engine hỗ trợ | **Không** | **R&D** qua DNS-aware companion | `chrome.dns` hiện chỉ dành cho Dev channel, trả IP chứ không chuỗi CNAME; chưa giải quyết Chrome Stable. | -| Chặn media theo kích thước response | Có | **Không tương đương chính xác** | **R&D** qua debugger/proxy | Header nếu có chưa bảo đảm kích thước body thực tế; bản hiện tại không triển khai bộ lọc kích thước đầy đủ. | +| Response body/HTML rewriting | Chỉ trên Firefox, cả với uBO đầy đủ | **Không** | **R&D** qua debugger/Fetch theo tab; proxy TLS cục bộ bị loại vì hạ cấp bảo mật HTTPS | DNR và managed `webRequestBlocking` không cung cấp body rewrite. Debugger có Fetch nhưng cần quyền không thể xin tùy chọn, quản lý request tạm dừng và target riêng. Probe Chrome 153: iframe tới mạng cục bộ gây hộp thoại Local Network Access; response do service worker của trang tạo ra bị bỏ sót; document đầu tiên của tab mở kèm URL bị bỏ sót; subresource của iframe khác site cần auto-attach. [Phân tích API](MV3-PARITY-IMPLEMENTATION-2026-09-06.md), [lưu ý đường debugger](MV3-LIMITS-RESEARCH-2026-09-24.md#lưu-ý-về-đường-debugger). | +| CNAME uncloaking/DNS resolution | Chỉ trên Firefox, cả với uBO đầy đủ | **Không** | **R&D** qua DNS-aware companion dùng resolver của hệ điều hành | Trên Chrome Stable 153, `chrome.dns` được cấp khi chạy với `--allowlisted-extension-id` cho extension khai báo `dns` trong manifest; gói Experimental không khai báo quyền này. Không có tham số đó thì manifest báo API cần Dev channel. Khi được cấp, `resolve()` chỉ trả một địa chỉ IP, không bao giờ trả chuỗi CNAME. Companion không phải policy vẫn để lọt request đầu tiên tới host mới. | +| Chặn media theo kích thước response | Có | **Không**, chưa triển khai | Không cần debugger/proxy: DNR biểu diễn được ngưỡng gần đúng | Hai rule DNR điều kiện `Content-Length` (`responseHeaders` với `values`/`excludedValues`) trên image/media chặn đúng các response từ 50.000 byte trong probe Chrome 153; mặc định của uBO là 50 KiB (51.200 byte) và người dùng đổi được. Mẫu glob của probe chỉ xét chữ số đầu; ngưỡng chính xác cần liệt kê thêm mẫu theo từng chữ số, chưa thử. Header là kích thước đã nén; response chunked không có `Content-Length` nên lọt (uBO cũng bỏ qua khi thiếu header). Rule theo header chạy ở `onHeadersReceived`, sau khi request đã gửi. Kết hợp với `topDomains` chưa thử. | +| Công tắc no-remote-fonts, no-csp-reports, no-scripting | Có | **Không**, chưa triển khai | Không cần: khoảng trống sản phẩm, không phải giới hạn trình duyệt | Probe Chrome 153: dynamic rule giới hạn bằng `topDomains` (Chrome 145+) chặn `font`, chặn `csp_report` (báo cáo `report-uri`), và chặn `script` kèm CSP chỉ trên site được chọn. Upload qua Reporting API (`report-to`) không hiện với DNR lẫn `webRequest`; chặn được hay không vẫn chưa rõ. | | Backup/restore | Có | **Có** | Có thể export policy riêng | Không bao gồm secret/native config nếu chưa có schema mã hóa. | | Internationalization | Có | **Có/Một phần** | Không cần | 10 locale được duy trì có đủ bộ chuỗi Power, gồm cả chuỗi firewall, logger, capability và cập nhật; 61 locale còn lại dùng English fallback khi build. | | Filter Store/catalog | Không phải core store | **MVP có** | Không cần | Catalog đóng gói và tối đa 8 catalog HTTPS do người dùng thêm; filter là dữ liệu, không phải plugin/code store. | @@ -35,7 +38,7 @@ Theo [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api - quota static/dynamic/session/regex do Chrome áp đặt; static không thể được “mượn” để tăng pool regex dynamic + session; - extension service worker thường bị dừng sau idle và có thể bị chấm dứt ngoài dự kiến; state quan trọng không được chỉ giữ trong global variables; - API DNR chỉ biểu diễn được một tập con semantics của engine MV2; -- extension MV3 bình thường không được dùng blocking `webRequest`; Chrome hỗ trợ chính thức quyền này cho policy-installed. Gói thử nghiệm còn kiểm chứng tham số allowlist trên Chrome 152, nhưng quyền đó không làm bản cài thành policy-installed và không mở toàn bộ API; +- extension MV3 bình thường không được dùng blocking `webRequest`; Chrome hỗ trợ chính thức quyền này cho policy-installed. Gói thử nghiệm kiểm chứng tham số allowlist trên Chrome 152 và lại trên Chrome 153. Chromium coi tham số này là công cụ test, hiện thanh cảnh báo cờ không được hỗ trợ và có thể gỡ nó ở bản bất kỳ. Quyền đó không làm bản cài thành policy-installed: phản hồi Promise vẫn bị bỏ qua; - JavaScript/Wasm từ xa không được thực thi. Filter/catalog tải qua HTTPS chỉ là dữ liệu hostile đi qua parser hữu hạn; không được biến thành remote scriptlet/module. Quota thay đổi theo phiên bản Chrome, vì vậy compiler phải đọc capability/quota khi có API và CI phải kiểm tra trên browser được hỗ trợ. Nguồn tham khảo: [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest), [blocking webRequest migration](https://developer.chrome.com/docs/extensions/develop/migrate/blocking-web-requests), [service-worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle), [MV3 remote hosted code](https://developer.chrome.com/docs/extensions/develop/migrate/remote-hosted-code), [uAssets #30545 về regex MV3](https://github.com/uBlockOrigin/uAssets/issues/30545) và [COMMUNITY-RESEARCH.md](COMMUNITY-RESEARCH.md). @@ -64,3 +67,11 @@ Một ô “Có” chỉ được đánh dấu shipped trong release note khi c 3. đo RAM/CPU trên profile máy yếu; 4. permission và threat-model review; 5. tài liệu fallback khi quota/API không đủ. + +Điều kiện 2 được kiểm tra tự động cho list stock. `tools/validate-mv3.mjs` từ chối gói khi số rule báo cáo khác số rule đóng gói, khi các số theo lý do không cộng đúng tổng filter bị từ chối, khi dòng `Unsupported` trong `log.txt` không khớp, hoặc, với build development, khi tổng đó khác số filter lỗi trong output debug của compiler. Còn ngoài phạm vi đếm, nên điều kiện 2 chưa đạt: + +- hostname entity/regex bị phủ định trong `domain=`, `to=`, `denyallow=` (ví dụ `domain=~example.*`) bị bỏ im lặng, không cảnh báo, làm rule áp cả cho site lẽ ra được loại trừ (187 filter trong bản chụp ngày 23/9/2026); +- hostname entity bị bỏ khi filter chỉ được chuyển một phần (cảnh báo trong `log.txt` chỉ có cho rule tĩnh không regex); +- hai trường hợp `$urlskip`. + +Tooltip của mỗi list stock trên dashboard hiện số filter đã chuyển và số filter bị từ chối. Số sau gồm cả filter không hợp lệ mà uBO cổ điển cũng từ chối, dù chuỗi hiển thị gọi đó là network filter không được hỗ trợ. Chưa có trang xem chi tiết theo lý do. diff --git a/docs/MV3-LIMITS-RESEARCH-2026-09-24.md b/docs/MV3-LIMITS-RESEARCH-2026-09-24.md new file mode 100644 index 0000000000000..8e5db69d8ff1b --- /dev/null +++ b/docs/MV3-LIMITS-RESEARCH-2026-09-24.md @@ -0,0 +1,241 @@ +# Nghiên cứu gỡ giới hạn MV3 của uBlock Plus+ + +Ngày nghiên cứu: **24 tháng 9 năm 2026**, nhánh `feature/compiler-honesty`. Chuẩn so sánh là **uBlock Origin đầy đủ**: mã nguồn `src/js` trong repository là tham chiếu ngữ nghĩa cho mọi filter option. Tài liệu này bổ sung [bảng tương thích](FEATURE-MATRIX.md), [rà soát ngày 6/9](MV3-CAPABILITY-AUDIT-2026-09-06.md) và [đợt triển khai parity ngày 6/9](MV3-PARITY-IMPLEMENTATION-2026-09-06.md). Các tài liệu ngày 6/9 vẫn giữ kết quả lịch sử của chúng. Những nhận định đã lỗi thời được liệt kê ở [phần cuối](#đính-chính-tài-liệu-cũ); trong [bảng tương thích](FEATURE-MATRIX.md) và [đợt triển khai parity](MV3-PARITY-IMPLEMENTATION-2026-09-06.md) chúng còn được đính chính tại chỗ. + +Câu hỏi: MV3 hạn chế nhiều tính năng của uBlock Origin gốc. Giới hạn nào gỡ được, bằng cách nào và với giá nào? + +**Kết luận ngắn:** + +- Trong Google Chrome **không thể gỡ mọi giới hạn**. MV2 đã bị tắt vô điều kiện. Blocking `webRequest` cần tham số khởi động hoặc cài bằng policy. Quyết định chặn bất đồng bộ (Promise) cần cài bằng policy. Chrome không có API lọc body dạng stream hay trả chuỗi CNAME cho extension. +- Phần lớn khoảng trống thực tế **vẫn thu hẹp được**. Nhiều mục chỉ là lỗi compiler hoặc tính năng chưa làm, không phải giới hạn trình duyệt: báo cáo filter bị bỏ, `$empty`/`$mp4`, lỗi biên dịch `$requestheader` thành rule chặn vô điều kiện (bản thân option vẫn cần engine Experimental), `##^responseheader()`, remote font, CSP report, media lớn, strict-block không tốn regex. +- Thứ tự đề xuất: sửa trong bản **Standard** trước (bước 1–5), rồi đưa engine mạng cổ điển vào bản **Experimental** (bước 6–7). Ai muốn gỡ hết giới hạn thì con đường trung thực là **bản Firefox** (bước 8). Cài bằng policy, companion CNAME và bản debugger chỉ là tầng tùy chọn, có ghi rõ rủi ro, không bao giờ âm thầm thay đổi hệ thống. +- **Bước 1 đã hoàn tất trong thay đổi này:** compiler báo phần lớn filter không chuyển được và các lỗi runtime/stock đã sửa. Những gì còn bỏ im lặng được liệt kê cuối phần [lộ trình](#lộ-trình-11-bước). + +## Phương pháp + +- **Mã nguồn Chromium 153** (tag `153.0.8010.53`): đọc cổng quyền, hằng số DNR, đường xử lý Promise của `webRequest`, API debugger và DNS. Các dẫn chứng dưới đây trỏ đúng dòng trên GitHub. +- **Google Chrome 153.0.8010.53 trên Windows 11**, với extension thử nhỏ viết riêng cho từng câu hỏi. Mỗi probe dùng một profile tạm mới dưới `%TEMP%` (`--user-data-dir`) và xóa sau khi xong. Chạy cả có lẫn không có `--allowlisted-extension-id`, headless (`--headless=new`) và có cửa sổ. Request đi tới server HTTP/HTTPS cục bộ qua `--host-resolver-rules`, nên không phụ thuộc website thật. Probe không cài chứng chỉ, không sửa registry hay policy. Lưu ý cho người chạy lại: trên Windows, ngay cả `chrome.exe --version` cũng mở trình duyệt với profile mặc định, nên luôn truyền một `--user-data-dir` mới. +- **Node 24.15.0:** biên dịch 55 list stock đã cache (443.276 filter, trong đó 244.330 network filter), khôi phục engine `static-net-filtering.js` từ snapshot và đo so khớp trên 15.301 URL request thật thu từ 46 website công khai (1.326 host). +- **Chạy lại cả hai compiler** (stock lúc build và runtime `ubo-parser.js`) trên cùng các list cache để đếm filter bị bỏ. +- **Kiểm chứng độc lập:** năm lượt kiểm chứng chạy lại các kết luận quan trọng nhất. Khi số liệu khác nhau, tài liệu dùng số đã kiểm chứng; các chỗ sửa được ghi rõ. +- Máy đo: AMD Ryzen 5 5600H, 8 GB RAM. Hồ sơ thô nằm trong `tmp/research/` (không đưa vào Git): `plan.json` (bảng giới hạn, lộ trình), `step1-claims.json` (bằng chứng theo file:dòng), các thư mục probe `gap-inventory`, `platform-facts`, `engine`, `bodies`, `synth` và `verify-0` … `verify-4`. + +## Kết luận + +### Những điều không thể làm trong Google Chrome + +1. **Chạy uBO cổ điển hay bất kỳ extension MV2 nào** trong Chrome 153+. [`ShouldDisableLegacyExtensions()`](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/browser/manifest_v2_handler.cc#L111-L118) luôn trả `true`; ngoại lệ duy nhất là một biến chỉ mã test bật được, không có tham số dòng lệnh hay policy nào. Nạp một extension MV2 qua CDP thất bại với lỗi `unsupported manifest version`. [Lịch ngừng MV2](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline). +2. **Tự cấp blocking `webRequest` từ bên trong extension.** Quyền [`webRequestBlocking`](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/common/api/_permission_features.json#L783-L792) trong MV3 chỉ dành cho `location: policy`; ngoài policy chỉ còn tham số `--allowlisted-extension-id` truyền lúc mở trình duyệt. Bản cài thường gọi `addListener(..., ['blocking'])` không bị lỗi, nhưng không nhận sự kiện nào. +3. **Quyết định chặn bất đồng bộ (Promise)** khi không cài bằng policy. Chrome [chỉ cho phép phản hồi bất đồng bộ](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/renderer/api/web_request_natives.cc#L92-L102) với MV3 cài từ policy, nên engine không thể chờ dữ liệu đọc bất đồng bộ trước khi trả lời. Dựng engine đồng bộ ngay khi worker nạp khiến Chrome giữ request lại chờ engine (xem [số đo](#engine-mạng-cổ-điển-trong-service-worker-experimental)), nhưng trạng thái người dùng vẫn đọc bất đồng bộ, nên vẫn còn một khoảng fail-open sau khi worker thức dậy. uBO cổ điển trên Chromium cũng không giữ được request, nhưng background page của nó không bao giờ ngủ. +4. **Nâng hạn mức DNR:** 50 static ruleset bật, 30.000 static rule bảo đảm, 30.000 dynamic (5.000 unsafe), 5.000 session, 1.000 regex mỗi pool, chương trình RE2 tối đa 2 KB, không lookaround hay backreference. +5. **Để `webRequest` đảo ngược quyết định DNR.** DNR block/redirect chạy trước và thắng; thay đổi header của DNR cũng thắng. Engine đặt trên DNR chỉ có thể chặn thêm. +6. **Lọc body dạng stream** (tương đương `filterResponseData`). Trong API extension, đường duy nhất tới body là `chrome.debugger`: đệm toàn bộ body, không chạm được response do service worker của trang tạo ra, quyền không thể là tùy chọn và có thanh cảnh báo. +7. **Phân giải CNAME trong trình duyệt.** Không API extension nào của Chrome trả chuỗi CNAME. Ngay cả khi được cấp, [`chrome.dns`](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/common/api/dns.webidl) chỉ trả một địa chỉ IP. Chỉ một process native bên ngoài làm được, và bản cài không phải policy không thể chờ nó trước request đầu tiên. +8. **Chặn subresource theo IP trước khi gửi** (`$ipaddress`). IP chỉ biết được sau khi response bắt đầu; uBO cổ điển trên Chromium cũng chỉ áp dụng cho `main_frame` sau thời điểm đó. +9. **Tự bật công tắc Allow User Scripts, biến bản cài thành policy-installed qua tham số dòng lệnh, hoặc ẩn thanh cảnh báo cờ không được hỗ trợ.** +10. **Lọc trang `chrome://`, Chrome Web Store hoặc trang của extension khác.** Đây không phải thụt lùi: uBO cổ điển trên Chrome cũng không làm được. + +### Ba tầng sản phẩm + +| Tầng | Cách chạy | Gỡ được | Không gỡ được | +| --- | --- | --- | --- | +| **Standard** (bản hiện tại) | Extension unpacked, DNR, không tham số đặc biệt | Báo cáo trung thực; sửa lỗi compiler; strict-block không tốn regex và giải phóng pool regex; công tắc remote font, CSP report, scripting, media lớn; `$inline-script`/`$inline-font`; một compiler cho mọi nguồn; logger, picker, popup, `$ipaddress` cho `main_frame` | Hạn mức DNR, RE2, `$redirect-rule`, regex/negated `$removeparam`, `$strict1p`/`$strict3p`. Chỉ gỡ một phần: entity/regex domain ([dòng 17](#bảng-41-giới-hạn), chưa thử), cập nhật stock list không qua bản phát hành (dòng 39) | +| **Experimental** | Launcher Windows mở profile riêng với `--allowlisted-extension-id` | Thêm engine mạng cổ điển (`static-net-filtering.js`) chạy trong một listener đồng bộ duy nhất: `$redirect-rule`, regex/negated `$removeparam`, ngoại lệ `$csp` chính xác, regex `$header=`, `$requestheader`, `$strict1p`/`$strict3p`, `$uritransform`, `$urlskip` tự động, entity/regex domain, regex ngoài RE2, không còn hạn mức rule, cập nhật list lúc chạy, firewall/URL rule/công tắc hostname cổ điển | Body/HTML filtering, CNAME, quyết định bất đồng bộ. DNR vẫn phải chạy làm nền | +| **Firefox** (bản mới) | Bản Plus+ cho Firefox với adapter engine | Các giới hạn còn lại của hai tầng trên, gồm [`filterResponseData`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/filterResponseData) cho `##^` và `$replace`, [`dns.resolve`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/dns/resolve) có `canonical_name`, và phản hồi Promise | Người dùng phải đổi trình duyệt; cần ký AMO. Mozilla giữ blocking `webRequest` trong MV3 ([thông báo 3/2024](https://blog.mozilla.org/addons/2024/03/13/manifest-v3-manifest-v2-march-2024-update/)) | + +Bản Firefox MV3 hiện có của Plus+ chỉ dùng DNR và không xin quyền `webRequest`, `dns` hay `webRequestFilterResponse`. + +### Tầng tùy chọn có rủi ro + +Không tầng nào dưới đây được bật mặc định. Mỗi tầng cần người dùng chủ động chọn, sau khi đọc tài liệu và rủi ro. + +- **Cài bằng policy (managed):** trên máy người dùng vốn đã quản lý (AD/Azure AD hoặc Chrome Enterprise Core), MV3 cài từ policy được phản hồi Promise, không cần tham số hay thanh cảnh báo, và debugger không hiện thanh cảnh báo. Đổi lại, console quản lý có quyền rộng trên trình duyệt. Extension và launcher không bao giờ tự enroll hay ghi policy. Chưa kiểm thử trên máy managed thật. +- **Companion CNAME native:** một process cài riêng dùng resolver của hệ điều hành. `Resolve-DnsName` trả đủ chuỗi, lookup đã cache mất khoảng 4 ms. Bản không cài bằng policy vẫn để lọt request đầu tiên tới host mới. Không dùng DoH: nó gửi mọi hostname cho bên thứ ba, và câu trả lời khác resolver hệ điều hành. +- **Bản “Body” dùng `chrome.debugger`** cho `##^` và `$replace`: xem các [lưu ý của đường debugger](#lưu-ý-về-đường-debugger). +- **Proxy chặn TLS cục bộ:** probe sửa được document, script, JSON, worker và iframe. Nhưng Chrome chỉ chấp nhận chứng chỉ khi chạy với `--ignore-certificate-errors-spki-list` (cũng nằm trong [danh sách cờ cảnh báo](https://github.com/chromium/chromium/blob/153.0.8010.53/chrome/browser/ui/startup/bad_flags_prompt.cc#L103-L107)) hoặc khi cài chứng chỉ gốc vào hệ thống. Không làm: đây là hạ cấp bảo mật HTTPS. +- **Trình duyệt Chromium còn MV2:** Brave tự lưu trữ uBO MV2 ([Brave về MV3](https://brave.com/blog/brave-shields-manifest-v3/)); các bản vá như [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) giữ MV2. Hỗ trợ MV2 ở các trình duyệt này chỉ là best-effort. Đây là sản phẩm khác, không phải capability của Google Chrome. Các nhận định trong mục này lấy từ nguồn thứ cấp và chưa được kiểm chứng: nghiên cứu không cài trình duyệt nào trong số đó. + +### Lưu ý về đường debugger + +Quyền `debugger` [không thể là optional](https://github.com/chromium/chromium/blob/153.0.8010.53/chrome/common/extensions/permissions/chrome_api_permissions.cc#L210-L213), nên cần một bản build riêng. Probe trên Chrome 153 xác nhận viết lại được document chính, document gzip, document 2,16 MB và iframe khác process, nhưng: + +- Chrome hiện thanh “đang gỡ lỗi trình duyệt này” trừ khi dùng `--silent-debugger-extension-api` hoặc cài bằng policy ([debugger_api.cc](https://github.com/chromium/chromium/blob/153.0.8010.53/chrome/browser/extensions/api/debugger/debugger_api.cc#L581-L586)). Tham số đó ẩn cảnh báo cho mọi extension trong process, nên không được tự thêm. +- **Iframe tới địa chỉ mạng cục bộ** kích hoạt hộp thoại [Local Network Access](https://developer.chrome.com/blog/local-network-access). Với cài đặt mặc định, trang thử không tải xong trong thời gian chờ; tắt kiểm tra LNA thì chạy bình thường. +- **Response do service worker của trang tạo ra** không đi qua Fetch của tab, nên bị bỏ sót. +- **Document đầu tiên của tab mở kèm URL** bị bỏ sót, vì attach đến muộn 5–13 ms. +- **Subresource của iframe khác site** chỉ bị chặn khi dùng auto-attach ([`Target.setAutoAttach`](https://chromedevtools.github.io/devtools-protocol/tot/Target/)) cho target con. +- Body được đệm toàn bộ (121–141 ms cho 2,16 MB), nên mất hiển thị dần. Phiên attach giữ worker sống. Theo tài liệu Chrome, mở DevTools sẽ detach extension (chưa thử). + +## Số liệu đo + +Mọi số dưới đây là của **một bản Chrome (153.0.8010.53) trên một máy (Ryzen 5 5600H, 8 GB RAM)**. Máy chậm hơn sẽ có khoảng chờ dài hơn. + +### Engine mạng cổ điển trong service worker (Experimental) + +Hai hồ sơ list: *mặc định* (6 list, 138.922 filter) và *nặng* (23 list, 203.598 filter). Engine được khôi phục từ snapshot structured-clone trong IndexedDB. + +| Đại lượng | Kết quả | +| --- | --- | +| Engine sẵn sàng sau mỗi lần worker khởi động thật | **60–75 ms** (mặc định), **75–95 ms** (nặng) | +| Tính từ lúc bắt đầu điều hướng | khoảng **92–109 ms** (mặc định), **107–122 ms** (nặng) | +| Request đi qua engine trước khi sẵn sàng | luôn có `main_frame`; tối đa **44** trong khoảng 150 request của trang thử, với khoảng nghỉ thực tế 5 s hoặc 35 s giữa các lần tải. Chỉ DNR nền lọc các request này | +| So khớp trong Chrome | **30–150 µs/request** trong vài trăm request đầu sau khi khởi động; **6–8 µs** khi đã nóng | +| So khớp trong Node (15.301 URL thật, sau khi JIT đã nóng) | chỉ so khớp: trung bình **5,55 µs/request** (p50 3,8 µs, p99 20,3 µs); cả pipeline (block, redirect-rule, removeparam, urlskip, csp/permissions, header=): trung bình 7,56 µs (p50 5,8 µs). Hồ sơ nặng: 7,96 / 10,39 µs | +| Bộ nhớ worker tăng thêm | **9–16 MiB** (JS heap cộng backing store) | +| Worker dừng khi rảnh | sau khoảng 30 s; mọi sự kiện `webRequest` đặt lại bộ đếm, nên worker ở lại trong lúc duyệt web | + +Kiểm chứng đã sửa các số ban đầu của nhóm nghiên cứu: thời gian sẵn sàng 48–67 ms thành 60–95 ms (đo với worker thực sự dừng rồi khởi động lại); khoảng chỉ có DNR “25–65 ms” thành engine sẵn sàng khoảng 92–122 ms sau khi bắt đầu điều hướng, tùy hồ sơ list; so khớp “tối đa khoảng 10 µs” thành 30–150 µs trong vài trăm request đầu (6–8 µs khi nóng); bộ nhớ “7–25 MB” thành 9–16 MiB. + +Hai phép đo quyết định thiết kế: + +- **Phản hồi Promise bị bỏ qua** khi không cài bằng policy. Cả bốn biến thể trả Promise (hủy ngay, hủy sau một lúc, hàm `async`, redirect) đều để request tới server, trong khi cùng quyết định trả đồng bộ thì có hiệu lực. Khi engine nạp bất đồng bộ (300 ms), cả 32/32 request của một đợt burst đánh thức worker đi qua listener trước khi engine sẵn sàng. +- **Dựng engine đồng bộ ở top level của worker** (từ JS literal, không đọc IndexedDB) khiến Chrome giữ request chờ: **0/32** request đi qua trước khi engine sẵn sàng, đổi lại trễ khoảng **100–115 ms** (chỉ đo với list mặc định). Dù vậy DNR nền vẫn cần: Chrome có thể được mở mà không có tham số allowlist, trạng thái người dùng (chế độ lọc, danh sách bật) vẫn đọc bất đồng bộ, và lần khởi động nguội của cả trình duyệt chưa được đo. + +Thứ tự quyết định: DNR block/redirect thắng `webRequest` (DNR block cộng `webRequest` redirect cho kết quả `ERR_BLOCKED_BY_CLIENT`; DNR allow priority 100 không cản được `webRequest` cancel). Rule DNR có điều kiện response header chạy ở giai đoạn `onHeadersReceived`, tức là sau khi request đã gửi. + +### Hằng số DNR trên Chrome 153 + +Theo [declarative_net_request.webidl](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/common/api/declarative_net_request.webidl#L884-L926), [constants.h](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/browser/api/declarative_net_request/constants.h#L296-L305) và probe vượt mỗi hạn mức thêm một rule: + +| Hạn mức | Giá trị | +| --- | --- | +| Static ruleset khai báo / bật | 100 / 50 | +| Static rule bảo đảm cho mỗi extension | 30.000 | +| Pool static toàn profile | 300.000 (`getAvailableStaticRuleCount()` trả 330.000 trên profile mới, tức 300.000 + 30.000 bảo đảm; 311.397 khi đã nạp Plus+) | +| Dynamic rule | 30.000, trong đó 5.000 unsafe | +| Session rule | 5.000 (5.000 unsafe) | +| Regex | 1.000 dùng chung cho dynamic + session; static ruleset đang bật có pool 1.000 riêng | +| Static rule bị tắt bằng `updateStaticRules` | 5.000 | +| Kích thước regex sau biên dịch | 2 KB; không lookaround, không backreference | + +Với sáu list mặc định: Chrome từ chối **80/254** regex rule stock (79 `memoryLimitExceeded`, 1 `syntaxError`). 174 regex còn lại cài thành dynamic rule, cộng 826 session rule của strict-block là đúng **1.000**. 278 strict-block rule hợp lệ bị bỏ, và filter nhập thêm hay cá nhân không còn chỗ cho regex. + +Probe pool regex: sau khi bật các static ruleset có tổng 902 regex, vẫn thêm được 1.000 dynamic regex. Với bản cài unpacked (cách Plus+ được phân phối), một regex sai cú pháp trong bất kỳ static ruleset nào, kể cả ruleset không bật, làm Chrome từ chối nạp cả extension. Ruleset làm tổng regex vượt 1.000 thì không được bật, và regex quá lớn bị bỏ qua mà không có cảnh báo. + +### Báo cáo compiler stock sau bước 1 + +Trước thay đổi này, `log.txt` ghi `Unsupported: 0` cho cả 55 list, còn `ruleset-details.json` ghi `rejected: 0`. Chạy lại cùng compiler trên cùng list cho thấy 1.310 **rule entry** có `_error` bị bỏ im lặng. Nay đơn vị đếm là **filter nguồn**: một filter sinh nhiều entry chỉ được đếm một lần, theo lỗi đầu tiên của nó. + +| Lý do | Số filter | +| --- | ---: | +| `unsupported-redirect-rule` | 530 | +| `unsupported-domain` (entity/regex trong `domain=`, `to=`, `denyallow=`) | 337 | +| `unsupported-regex` | 92 | +| `unsupported-removeparam-regex` | 76 | +| `unsupported-ipaddress` | 54 | +| `invalid-filter` | 53 | +| `unsupported-strict-first-party` | 22 | +| `unsupported-urltransform-regex` | 17 | +| `unsupported-strict-third-party` | 14 | +| `unsupported-header-value` | 6 | +| `invalid-network-filter` | 5 | +| `unsupported-redirect-resource` | 4 | +| `unsupported-removeparam-negated` | 2 | +| `unsupported-requestheader` | 1 | +| **Tổng, trong 32/55 list** | **1.213** | + +Tổng này gồm hai loại. Khoảng **1.155** filter hợp lệ nhưng DNR không biểu diễn được. **58** filter còn lại (`invalid-filter` 53, `invalid-network-filter` 5) là filter không hợp lệ mà uBO cổ điển cũng từ chối; 51 trong số đó không phải network filter mà là cosmetic hoặc scriptlet filter có lỗi cú pháp, ví dụ `gamepadla.com#?#.pMain:has(div:-abp-properties(Partner))`. + +Build đạt validator: 55 ruleset, 70.310 static DNR rule (cộng 607 regex rule đóng gói trong `rulesets/regex`, cài thành dynamic rule lúc chạy khi list được bật). Rule được sinh ra giữ nguyên, trừ các chỗ sửa có chủ ý: hai rule removeparam phủ định trong `adguard-spyware-url` bị bỏ, rule `##^responseheader()` trong `ublock-filters`, `irn-0` và `rus-0` được dựng lại, và hai rule removeparam `main_frame` quá rộng trong `ublock-filters` bị bỏ. + +## Bảng 41 giới hạn + +Cột **Kết luận** giữ nguyên phán quyết của nghiên cứu: **Standard** (gỡ được trong bản DNR), **Experimental** (cần bản có blocking `webRequest`), **Một phần**, **Companion** (cần process native), **Trình duyệt khác**. “Bước 1” đánh dấu các dòng đã sửa trong thay đổi này. + +| # | Giới hạn | Hiện trạng | Kết luận | Cách gỡ và lưu ý | +| ---: | --- | --- | --- | --- | +| 1 | uBO cổ điển (MV2) không chạy được trong Chrome | Không áp dụng; Plus+ buộc phải là MV3 | Trình duyệt khác | Firefox, Brave, bản Chromium vá MV2. Trong Chrome chỉ gỡ từng giới hạn theo các dòng dưới | +| 2 | Không có blocking `webRequest` | Standard: không. Experimental: có qua launcher, chỉ dùng cho firewall `block` | Experimental | Đặt engine cổ điển lên đường launcher (bước 6). Đã xác minh cancel, redirect tới `data:` và resource web-accessible, redirect `main_frame`, thêm/xóa response header, thêm request header | +| 3 | Không có background page thường trực; không giữ được request | Standard: DNR luôn chạy. Experimental: Promise bị bỏ qua, worker ngủ sau khoảng 30 s | Một phần | Đăng ký listener đồng bộ; khôi phục engine từ IndexedDB (60–95 ms) hoặc dựng đồng bộ ở top level (Chrome giữ request, trễ khoảng 100–115 ms với list mặc định); luôn giữ DNR nền. Tránh top-level `await` (worker không khởi động) và import JSON module (khởi động lại liên tục) | +| 4 | Hạn mức rule DNR | Xem [hằng số](#hằng-số-dnr-trên-chrome-153) | Experimental | Engine cổ điển không có hạn mức (đã đo 203.598 filter); thêm 9–16 MiB bộ nhớ worker. Standard không nâng được | +| 5 | Pool 1.000 regex dynamic + session đã đầy với list mặc định | 174 + 826 = 1.000; 278 strict-block rule bị bỏ | Standard | (a) Strict-block bằng redirect `extensionPath` tới `/strictblock.html`, lấy URL gốc từ `webNavigation.onBeforeNavigate`: không cần regex. (b) Chuyển regex stock hợp lệ sang static ruleset (pool riêng). Build phải kiểm cú pháp regex; runtime phải báo ruleset/regex không được bật. Trang strict-block chỉ hiện đúng URL với điều hướng `main_frame` trực tiếp. Sau server redirect nó hiện URL ban đầu của điều hướng (trang chuyển hướng) thay vì URL bị chặn; sau back/forward có thể hiện URL của một điều hướng khác; tab nhân bản (Duplicate) không có URL và hiện `null` | +| 6 | RE2: không lookaround/backreference, tối đa 2 KB | 80/254 regex mặc định bị từ chối | Experimental | Engine cổ điển dùng `RegExp` của JavaScript; chỉ có hiệu lực sau khi engine sẵn sàng | +| 7 | `$redirect-rule` (530 filter) | Không hỗ trợ; nay được đếm và báo | Experimental | Engine chỉ redirect khi request vốn bị chặn. Rule DNR block trùng request phải bị loại khỏi bản này, vì DNR block thắng redirect của `webRequest` | +| 8 | Resource `$redirect=` cho filter nhập thêm/cá nhân (10/47 chưa đóng gói) | **Đã sửa (bước 1):** đóng gói và khai báo web-accessible 46 resource cùng alias. `click2load.html` bị cả hai compiler từ chối và ghi trong `rulesets/redirect-resources.json` | Standard | Build dừng nếu thiếu file resource. Filter `redirect=` trỏ resource không dùng được bị bỏ (uBO cổ điển vẫn chặn request đó) | +| 9 | `$empty`, `$mp4` ở runtime | **Đã sửa (bước 1):** redirect tới `empty` và `noop-1s.mp4` như stock thay vì chặn mọi loại request | Standard | Ngoại lệ `@@…$empty/$mp4/$redirect` bị từ chối (`unsupported-redirect-exception`) | +| 10 | `$requestheader` | **Đã sửa ở Standard (bước 1):** runtime từ chối thay vì chặn vô điều kiện; stock đếm và báo | Experimental | Engine ghi header ở `onBeforeSendHeaders` rồi hủy ở `onHeadersReceived` như uBO cổ điển; request đã được gửi đi | +| 11 | `$removeparam` regex, phủ định, không giá trị | **Bước 1:** bare `$removeparam` xóa cả query (`query: ''`); stock báo giá trị phủ định thay vì sinh rule không bao giờ khớp. Regex vẫn không hỗ trợ | Experimental | Engine xóa tham số bằng redirect; tránh vòng lặp redirect nếu DNR cũng có rule đó | +| 12 | Ngoại lệ `$csp`/`$permissions` quá rộng | Ngoại lệ thành allow priority 1, hủy cả rule header/removeparam khác | Experimental | Standard (một phần): trừ domain của ngoại lệ lúc biên dịch. Experimental: chèn CSP chính xác ở `onHeadersReceived`, bỏ rule csp/permissions khỏi DNR | +| 13 | `$header=` regex/phức tạp | Chỉ giá trị chính xác hoặc glob | Experimental | So khớp ở `onHeadersReceived` rồi hủy | +| 14 | `$uritransform` | Bị từ chối; `@@…$uritransform` ở runtime nay bị từ chối (bước 1) thay vì thành redirect | Experimental | Redirect trong `onBeforeRequest`, chỉ cho nguồn tin cậy. Standard có thể dùng `regexSubstitution` khi pool regex được giải phóng | +| 15 | `$urlskip` | Chỉ list stock, dạng liên kết thủ công trên trang strict-block | Experimental | Tự chuyển hướng `main_frame` bằng `src/js/urlskip.js`, có kiểm tra độ tin cậy của nguồn | +| 16 | `$strict1p`/`$strict3p` | Không hỗ trợ (DNR không có điều kiện hostname bằng nhau) | Experimental | Engine cổ điển | +| 17 | Entity (`example.*`) và regex trong `domain=`, `to=`, `denyallow=` | 337 filter stock bị bỏ, có filter khác được “salvage” bằng cách bỏ phần không hỗ trợ. Hostname entity/regex bị phủ định (`domain=~example.*`) bị bỏ im lặng, không cảnh báo, không đếm, làm rule rộng hơn: 187 filter trong bản chụp này, ví dụ `/data/banner.json?$domain=~nintendo.*` (`kor-1`) thành rule chặn trên mọi site, kể cả site bị loại trừ | Experimental | Engine khớp chính xác. Standard (chưa thử): mở rộng `example.*` theo public suffix lúc build | +| 18 | `$ipaddress` | Không hỗ trợ | Standard | Như uBO trên Chromium: đọc `details.ip` ở `onResponseStarted` (quyền `webRequest` tùy chọn) rồi đưa tab tới trang strict-block. Chỉ cho `main_frame`, sau khi document bắt đầu tải. Chưa xây dựng. Đây là thụt lùi thật so với uBO cổ điển trên Chromium | +| 19 | `$popup` có điều kiện party, `$popunder` stock | 45 filter mặc định bị hoãn; stock bỏ `$popunder` | Standard | uBO cổ điển cũng chặn popup qua `webNavigation`. Dùng matcher đầy đủ trong observer, nạp lười | +| 20 | `$inline-script`, `$inline-font`, ô firewall inline-script | Bị từ chối/bỏ | Standard | Chèn CSP bằng DNR `modifyHeaders` trên `main_frame`/`sub_frame`, giới hạn bằng `topDomains`; đã xác minh | +| 21 | `$ghide`/`$ehide`/`$shide` ở runtime | Runtime từ chối (1.743 trong corpus) | Standard | Sinh generic-cosmetic exclusion như stock (bước 4) | +| 22 | `$badfilter` nhắm rule stock | Chỉ dựng lại nhóm hostname chặn; phần khác hoãn | Standard | Áp mọi `$badfilter` stock-stock lúc build; `disableRuleIds` cho filter người dùng (tối đa 5.000) | +| 23 | Build stock bỏ rule im lặng | **Đã sửa (bước 1):** đếm theo lý do trong `log.txt`, `ruleset-details.json`, tổng lúc build; validator đối chiếu; tooltip của dashboard hiện số filter không chuyển được thành quy tắc Chrome | Standard | Vẫn chưa được đếm: hostname entity bị bỏ khỏi filter chuyển một phần, hostname entity/regex bị phủ định (dòng 17, bỏ im lặng) và hai trường hợp `$urlskip` | +| 24 | Strict-block cho `$doc` nhập thêm/cá nhân | Chặn DNR thường, không có nút tiếp tục | Standard | Như dòng 5, cộng allow session tạm để tiếp tục | +| 25 | Generic cosmetic, ngoại lệ generic, hostname chỉ phủ định, `*##` trong list nhập thêm | Runtime bỏ | Standard | Dùng lại pipeline generic-cosmetic của stock, chèn qua `userScripts` | +| 26 | Cosmetic/scriptlet runtime cần công tắc Allow User Scripts | Extension không tự bật được | Một phần | Phát hiện và hướng dẫn; đường stock vẫn chạy khi tắt. Trên Chrome 153 API xuất hiện trong worker đang chạy mà không cần reload. [userScripts](https://developer.chrome.com/docs/extensions/reference/api/userScripts) | +| 27 | Scriptlet `trusted-*` từ list nhập thêm | Không bao giờ tin | Standard | Tin list có URL khớp đúng tiền tố tin cậy của build, như uBO | +| 28 | HTML filtering (`##^`, 232 filter). Chỉ có trên Firefox ngay cả với uBO cổ điển | Bỏ im lặng | Một phần | Chỉ qua debugger (bản riêng, bước 11 của lộ trình). Tương đương stream cần Firefox | +| 29 | `$replace` (15 filter trong list mặc định). Chỉ có trên Firefox với uBO cổ điển | Bị từ chối | Một phần | Như dòng 28, phạm vi rộng hơn (XHR, script, JSON, HLS, DASH), khoảng +1 ms mỗi request bị `Fetch` tạm dừng để xử lý (đo trên loopback) | +| 30 | Ngữ nghĩa `##^responseheader()` | **Stock đã sửa (bước 1):** khóa theo hostname của chính response (`requestDomains`), áp mọi loại tài nguyên, tôn trọng ngoại lệ. Runtime vẫn bỏ im lặng | Standard | Còn gần đúng: ngoại lệ chỉ áp trong cùng list; `@@` mạng khớp cùng request hủy việc xóa header. Thêm nhánh runtime ở bước 4. Khác với HTML filtering, đây là thụt lùi thật so với uBO cổ điển trên Chromium | +| 31 | CNAME uncloaking. Chỉ có trên Firefox với uBO cổ điển | Không có. `chrome.dns` được cấp dưới tham số allowlist cho extension khai báo `dns` trong manifest (gói Experimental không khai báo), nhưng chỉ trả một IP | Companion | Companion native dùng resolver hệ điều hành (bước 10); bản policy có thể chờ lookup | +| 32 | Công tắc no-remote-fonts, no-csp-reports, no-scripting | Chưa làm | Standard | Mỗi công tắc một dynamic rule với `topDomains` (Chrome 145+): chặn `font`; chặn `csp_report`; chặn `script` và thêm CSP. Đã xác minh cả ba. Upload Reporting API (`report-to`) không hiện với DNR lẫn `webRequest` | +| 33 | Công tắc no-large-media | Chưa làm | Standard | Hai rule DNR theo `Content-Length` trên image/media chặn đúng từ 50.000 byte, một ngưỡng gần đúng: mẫu glob của probe chỉ xét chữ số đầu, còn uBO mặc định 50 KiB (51.200 byte) và người dùng đổi được giá trị này. Ngưỡng chính xác cần liệt kê thêm mẫu theo từng chữ số, chưa thử. Là kích thước đã nén; response chunked không có header nên lọt, như uBO khi thiếu header; request đã được gửi khi rule chạy. Kết hợp với `topDomains` chưa thử | +| 34 | Công tắc no-popups, no-cosmetic-filtering, no-strict-blocking | Có cơ chế gần tương đương | Standard | Đưa ra giao diện công tắc kiểu cổ điển, ghi rõ khác biệt | +| 35 | Ma trận firewall (tối đa 256 ô, không inline-script, không `main_frame`, không ma trận trong popup) | Một phần, trình soạn văn bản | Standard | Nâng giới hạn ô trong ngân sách 30.000 dynamic rule; inline-script qua CSP; giao diện ma trận. Experimental: port `dynamic-net-filtering.js` | +| 36 | URL rule với ngữ nghĩa `noop` | Chưa làm | Experimental | Port `url-net-filtering.js` và `hnswitches.js` theo thứ tự URL rule → firewall → static | +| 37 | Độ chi tiết logger | 512 bản ghi, hiện JSON rule DNR | Standard | Bản đồ rule ID → list, dòng, filter từ cả hai compiler; tạo filter từ logger | +| 38 | Picker chỉ tạo cosmetic filter | Chưa có ứng viên network | Standard | Port `netFilterCandidates` từ `src/js/scriptlets/epicker.js` | +| 39 | List stock chỉ cập nhật cùng bản phát hành | Không có Diff-Path | Experimental | Engine biên dịch list tải về trong offscreen. Standard (một phần): tắt rule stock đã xóa bằng `updateStaticRules`, thêm rule mới thành dynamic | +| 40 | Hai compiler khác nhau | Runtime từ chối/hoãn 4.231 filter của corpus stock (đo trước bước 1) | Standard | Chạy `static-net-filtering.js`/`static-dnr-filtering.js` trong offscreen cho mọi nguồn (bước 4). Bước 1 đã đồng bộ `$empty`, `$mp4`, `$requestheader` và resource redirect; các mã lý do dùng chung được viết giống nhau, nhưng domain và header vẫn khác mã (stock `unsupported-domain`, `unsupported-header-value`; runtime `invalid-from-domain-list`, `unsupported-response-header-regex`…) | +| 41 | Năng lực đầy đủ như Firefox: lọc body stream, CNAME chính xác, giữ request | Bản Firefox MV3 hiện có chỉ dùng DNR | Trình duyệt khác | Bản Firefox với adapter engine (bước 8) | + +## Lộ trình 11 bước + +| Bước | Nội dung | Công sức ước tính | Trạng thái | +| ---: | --- | --- | --- | +| 1 | Báo cáo trung thực và sửa lỗi compiler (cả hai bản) | 3–5 ngày | **Đã xong trong thay đổi này**, xem bên dưới | +| 2 | Giải phóng pool regex ở Standard: strict-block không dùng regex, regex stock sang static ruleset | 5–8 ngày | Cần kiểm cú pháp regex lúc build và job CI nạp gói trong Chrome thật | +| 3 | Công tắc theo site và option dựa trên CSP ở Standard: fonts, CSP report, scripting, media lớn, `$inline-script`/`$inline-font` | 6–10 ngày | Cần Chrome 145+ (`topDomains`) | +| 4 | Một compiler cho stock, list nhập thêm và bộ lọc cá nhân | 10–15 ngày | Cần ghim phiên bản engine `src/js` và test đối chiếu hai đường | +| 5 | Logger, picker và popup ngang uBO; `$ipaddress` cho `main_frame` | 7–10 ngày | Sau bước 4 | +| 6 | Experimental: engine mạng cổ điển chạy trong một listener đồng bộ | 20–30 ngày | Engine sẵn sàng 60–95 ms sau mỗi lần worker khởi động (hoặc dựng đồng bộ, trễ khoảng 100–115 ms, chỉ đo với list mặc định); +9–16 MiB; vẫn cần DNR nền | +| 7 | Experimental: firewall động, URL rule và công tắc hostname từ `src/js` | 5–10 ngày | Sau bước 6 | +| 8 | Bản Firefox của Plus+ với engine đầy đủ | 15–25 ngày (ước đoán) | Cần ký AMO, rà soát ID và thương hiệu | +| 9 | Adapter cài bằng policy, chỉ tài liệu | 5–10 ngày và một môi trường managed | Không bao giờ tự enroll hay ghi policy | +| 10 | Companion CNAME native opt-in (RFC trước) | 15–20 ngày | Cần rà soát quyền riêng tư; chỉ resolver hệ điều hành | +| 11 | R&D: bản “Body” dùng `chrome.debugger` cho `##^` và `$replace` | 20–40 ngày | Tỷ lệ giá trị/công sức thấp nhất | + +**Bước 1 đã giao:** + +- **Build stock:** filter không chuyển được được đếm theo lý do trong `log.txt`, `ruleset-details.json` (`rules.rejected`, `rules.rejectedReasons`, `filters.converted`) và tổng cuối build. `tools/validate-mv3.mjs` đối chiếu các số đó với file đã đóng gói. Tooltip của mỗi list stock trên dashboard dùng số filter đã chuyển thật (ví dụ `ublock-filters` 10.498 → 7.847) và thêm một dòng nêu số filter bị từ chối (`rules.rejected`), có bản dịch cho 10 ngôn ngữ được duy trì. Chuỗi hiển thị gọi đó là network filter mà Chrome không hỗ trợ, nhưng tổng này gồm cả 58 filter không hợp lệ mà uBO cổ điển cũng từ chối (51 là cosmetic/scriptlet, xem [bảng lý do](#báo-cáo-compiler-stock-sau-bước-1)). +- **`##^responseheader()` stock** khóa theo hostname của response, tôn trọng ngoại lệ, áp cho mọi loại tài nguyên. Filter chỉ có hostname entity/regex bị đếm là không hỗ trợ. +- **`removeparam=~name`** stock được báo thay vì sinh rule không bao giờ khớp. Khi filter removeparam có `domain=` cùng hostname pattern hoặc `to=`, rule cho lượt tải trang chỉ giữ hostname nằm trong cả hai danh sách; rule không thể khớp bị bỏ (cả stock lẫn runtime). +- **Resource redirect:** 46 resource đóng gói và web-accessible; `click2load.html` bị từ chối ở cả hai compiler. +- **Compiler runtime:** `$requestheader` bị từ chối; `$empty`/`$mp4` thành redirect; bare `$removeparam` xóa query; `@@…$uritransform` bị từ chối; `$permissions` nối nhiều policy bằng `, `; `important` trên removeparam/uritransform lấy priority 2 (dưới mọi block), trên csp/permissions lấy 31; `||host^$to=x` giữ cả hai điều kiện. +- Revision của compiled cache (`COMPILED_FILTERS_REVISION`) tăng lên 4, nên list nhập thêm đã biên dịch bằng compiler cũ được biên dịch lại thay vì giữ rule cũ. +- Test hồi quy: `tools/test-stock-compiler-honesty.mjs`, `tools/test-runtime-filter-options.mjs`. + +**Còn mở sau bước 1:** + +- Hostname entity/regex bị phủ định trong `domain=`, `to=` và `denyallow=` (ví dụ `domain=~example.*`) bị bỏ im lặng: không cảnh báo, không đếm. Rule còn lại áp cả cho các site lẽ ra được loại trừ. Bản chụp này có 187 filter như vậy (362 rule entry); ví dụ `||appleid.*.no^$domain=~en-svindelside-som-ikke-er-apple.*` (`nor-0`) thành rule chặn không điều kiện domain. +- 433 hostname entity/regex trong 151 rule entry stock (không tính entry đã bị từ chối) bị bỏ khi filter được chuyển một phần; chúng không được đếm là bị từ chối. Cảnh báo chỉ được ghi vào `log.txt` cho rule tĩnh không regex (144 dòng, 421 hostname); rule regex, strict-block và popup không có cảnh báo. +- Hai trường hợp `$urlskip` vẫn bị bỏ không báo. +- Runtime chưa có nhánh `##^responseheader()`. +- Compiler stock biến `||host^$to=x` thành `requestDomains: [host, x]`, tức chặn cả `x`. Probe Node trên 25 filter `to=` dương của list stock hiện tại không thấy rule nào bị ảnh hưởng. +- Ngoại lệ `@@…$redirect=` ở stock thành block priority 21, chặn cả khi không có filter nào khác chặn. +- Tooltip chỉ hiện số tổng cho list stock, và số đó gồm cả filter không hợp lệ dù chuỗi hiển thị gọi là network filter không được hỗ trợ; chưa có trang xem theo lý do hay dòng nguồn. + +## Lưu ý trung thực + +- **Tham số `--allowlisted-extension-id` là công cụ test.** Chromium kiểm tra nó bằng [`IsAllowlistedForTest`](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/common/features/simple_feature.cc#L735-L743); tham số nằm trong [danh sách cờ cảnh báo](https://github.com/chromium/chromium/blob/153.0.8010.53/chrome/browser/ui/startup/bad_flags_prompt.cc#L119-L121), nên Chrome hiện thanh cảnh báo mỗi phiên, và có thể bị gỡ ở bất kỳ bản phát hành nào. +- Tham số mở **mọi** tính năng bị chặn theo allowlist hoặc vị trí cài cho ID đó, không chỉ `webRequestBlocking`: probe khai báo `dns` trong manifest cũng nhận được `chrome.dns`. Tham số không cấp quyền mà manifest không khai báo; gói Experimental không khai báo `dns`. Nó không biến bản cài thành policy-installed: `installType` vẫn là `development` và phản hồi Promise vẫn bị bỏ qua. +- Chưa thử mở lại cùng một profile với tham số này qua nhiều lần khởi động, và chưa đo lần khởi động nguội của cả trình duyệt. Launcher hiện chỉ có cho Windows. +- Mọi số đo là của một bản Chrome và một máy, phần lớn với server cục bộ; không phải khảo sát website thực tế. Các kết luận Standard về strict-block, pool regex, `topDomains` và `Content-Length` được xác minh hai lần (probe `synth` và lượt kiểm chứng `verify-3`), trên cùng một máy. +- Công sức là ước tính của nhóm nghiên cứu, không phải cam kết. Bước 8 chưa được khảo sát chi tiết. +- Số filter bị từ chối phụ thuộc bản chụp list ngày 23/9/2026 (build ngày 24/9, dùng lại list đã tải) và sẽ thay đổi khi list cập nhật. + +## Đính chính tài liệu cũ + +- **`chrome.dns`:** trên Chrome Stable 153, API này được cấp khi chạy với `--allowlisted-extension-id` cho extension khai báo `dns` trong manifest (gói Experimental không khai báo); không có tham số thì manifest báo cần Dev channel. Khi được cấp, `resolve()` chỉ trả một IP và mã kết quả, không bao giờ trả chuỗi CNAME. Đã sửa trong [FEATURE-MATRIX.md](FEATURE-MATRIX.md) và [MV3-PARITY-IMPLEMENTATION-2026-09-06.md](MV3-PARITY-IMPLEMENTATION-2026-09-06.md). [USERSCRIPTS-AND-MV3-2026-09-06.md](USERSCRIPTS-AND-MV3-2026-09-06.md) vẫn ghi `chrome.dns` “hiện dành cho Dev channel”; câu đó chưa được sửa tại chỗ và cần đọc theo đính chính này. +- **no-large-media:** DNR biểu diễn được bằng hai rule `Content-Length`, không cần debugger hay proxy. Giới hạn: kích thước đã nén, response chunked không được xét, và ngưỡng đã thử là 50.000 byte chứ không phải đúng 51.200 byte của uBO. +- **Remote font và CSP report qua `report-uri`:** là khoảng trống sản phẩm mà DNR chặn được, không phải giới hạn trình duyệt. Upload qua Reporting API (`report-to`) không hiện với DNR lẫn `webRequest` trong probe; chặn được hay không vẫn chưa rõ (ví dụ bằng cách xóa header `Reporting-Endpoints`, chưa thử). +- **Đường debugger:** bổ sung các lưu ý Local Network Access, service worker của trang, tab mới và auto-attach ở [trên](#lưu-ý-về-đường-debugger). +- **Regex:** tài liệu ngày 6/9 nêu sáu ngoại lệ regex bị Chrome 152 từ chối; với list mặc định, Chrome 153 từ chối 80/254 regex rule stock. +- **Báo cáo compiler:** bảng tương thích từng coi “không có silent drop” là điều kiện phát hành trong khi build stock vẫn ghi `Unsupported: 0`. Nay số filter bị từ chối được báo và kiểm tra, nhưng điều kiện đó vẫn chưa đạt: hostname entity/regex bị phủ định vẫn bị bỏ im lặng (xem [việc còn mở](#lộ-trình-11-bước)). diff --git a/docs/MV3-PARITY-IMPLEMENTATION-2026-09-06.md b/docs/MV3-PARITY-IMPLEMENTATION-2026-09-06.md index 66af9dc6a7154..0707c385142a1 100644 --- a/docs/MV3-PARITY-IMPLEMENTATION-2026-09-06.md +++ b/docs/MV3-PARITY-IMPLEMENTATION-2026-09-06.md @@ -2,6 +2,8 @@ Đợt triển khai tiếp theo [báo cáo rà soát](MV3-CAPABILITY-AUDIT-2026-09-06.md), ngày 6 tháng 9 năm 2026. Chuẩn đối chiếu là **uBlock Origin đầy đủ**. Tài liệu này thay thế các nhận định “chưa triển khai” tương ứng trong báo cáo trước; không thay đổi kết quả kiểm thử lịch sử. +> Cập nhật 24/9/2026: [nghiên cứu gỡ giới hạn MV3](MV3-LIMITS-RESEARCH-2026-09-24.md) kiểm tra lại các giới hạn trên Chrome 153. Các đoạn về `chrome.dns`, chặn theo kích thước response, đường debugger và giới hạn regex dưới đây đã được đính chính tại chỗ và ghi ngày; kết quả kiểm thử ngày 6/9 giữ nguyên. + ## Firewall động Mở **Dashboard → Site rules → Dynamic firewall**. Dùng cú pháp bốn cột của uBO: @@ -75,8 +77,9 @@ Tra cứu stock chỉ đọc file được manifest khai báo; không dựng URL ## Giới hạn của cấu hình hiện tại và các đường mở rộng -- `webRequest` dùng quan sát ở trên không cấp quyền blocking đồng bộ; `webRequestBlocking` MV3 thông thường chỉ có trong trường hợp cài bằng enterprise policy phù hợp. -- Cấu hình DNR/`webRequest` hiện tại không có bộ lọc response body tổng quát, CNAME uncloaking hay chặn theo toàn bộ kích thước response tương đương uBO trên Firefox. Đây là giới hạn của đường thực thi đang dùng, không phải khẳng định mọi cách triển khai Chrome extension đều bất khả thi. +- `webRequest` dùng quan sát ở trên không cấp quyền blocking đồng bộ; `webRequestBlocking` MV3 thông thường chỉ có trong trường hợp cài bằng enterprise policy phù hợp. Gói [Experimental WebRequest](EXPERIMENTAL-WEBREQUEST.md) dùng tham số test `--allowlisted-extension-id` thay cho policy. +- Cấu hình DNR/`webRequest` hiện tại không có bộ lọc response body tổng quát hay CNAME uncloaking; uBO đầy đủ cũng chỉ có hai khả năng đó trên Firefox. Đây là giới hạn của đường thực thi đang dùng, không phải khẳng định mọi cách triển khai Chrome extension đều bất khả thi. +- Cập nhật 24/9/2026: chặn media theo kích thước **không** cần body hay `webRequest`. uBO đầy đủ cũng chỉ đọc header `Content-Length`, và DNR biểu diễn được ngưỡng gần đúng bằng hai rule `responseHeaders`: probe Chrome 153 chặn đúng từ 50.000 byte, với mẫu glob chỉ xét chữ số đầu. Mặc định của uBO là 51.200 byte và người dùng đổi được; ngưỡng chính xác cần liệt kê thêm mẫu theo từng chữ số, chưa thử. Giới hạn khác: header là kích thước đã nén, response chunked không có header nên lọt. Tính năng này chưa được triển khai. Remote font và báo cáo CSP qua `report-uri` cũng chặn được bằng DNR (`font`, `csp_report`); đó là khoảng trống sản phẩm, không phải giới hạn trình duyệt. Upload qua Reporting API (`report-to`) không hiện với DNR lẫn `webRequest`. - Scriptlet có thể xử lý một số API/DOM trong trang bằng primitive đóng gói; không thể dùng nó thay một response filter toàn cục hoặc khẳng định thấy request ngoài phạm vi Chrome cho phép. - Tầng native, proxy hoặc browser tùy biến là sản phẩm triển khai khác, cần cài đặt/quyền/phân tích riêng. Đợt này không cài companion, chứng chỉ, sửa policy hoặc tắt sandbox/quota. @@ -90,10 +93,20 @@ Tuy nhiên, Chrome **không cho khai báo `debugger` trong `optional_permissions Phiên debugger có thể bị Chrome kết thúc khi mở DevTools hoặc đóng tab; target iframe khác process cần xử lý riêng. Debugger đang hoạt động còn giữ service worker sống, nên đây không phải nâng cấp miễn phí về tài nguyên. Các điểm này cần benchmark và giao diện trạng thái trước khi phát hành, không được đánh đồng với logger nhẹ hiện có. [Vòng đời debugger](https://developer.chrome.com/docs/extensions/reference/api/debugger#event-onDetach), [Vòng đời service worker](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle#chrome-118). -Chrome còn có `chrome.dns`, nhưng tài liệu hiện giới hạn API này ở **Dev channel**; `resolve()` trả về địa chỉ IP và mã kết quả, không cung cấp chuỗi CNAME. API đó không phải giải pháp CNAME uncloaking cho gói nhắm Chrome Stable hiện tại. [Chrome DNS](https://developer.chrome.com/docs/extensions/reference/api/dns). +Cập nhật 24/9/2026, probe Chrome 153 viết lại body qua `Fetch` ([chi tiết](MV3-LIMITS-RESEARCH-2026-09-24.md#lưu-ý-về-đường-debugger)): + +- iframe tới địa chỉ mạng cục bộ kích hoạt hộp thoại [Local Network Access](https://developer.chrome.com/blog/local-network-access); với cài đặt mặc định, trang thử không tải xong trong thời gian chờ; +- response do service worker của trang tạo ra không đi qua `Fetch` của tab nên bị bỏ sót; +- document đầu tiên của tab mở kèm URL bị bỏ sót, vì attach đến muộn vài mili giây; +- subresource của iframe khác site chỉ bị xử lý khi dùng auto-attach ([`Target.setAutoAttach`](https://chromedevtools.github.io/devtools-protocol/tot/Target/)) cho target con; +- body được đệm toàn bộ: 121–141 ms cho document 2,16 MB. + +Chrome còn có `chrome.dns`. Tài liệu giới hạn API này ở **Dev channel**, và trên Chrome Stable 153 không có tham số thì manifest báo đúng như vậy. Nhưng khi chạy với `--allowlisted-extension-id`, API được cấp trên Stable 153 cho extension khai báo `dns` trong manifest (cập nhật 24/9/2026; gói Experimental không khai báo quyền này). Dù được cấp, `resolve()` chỉ trả một địa chỉ IP và mã kết quả, không bao giờ trả chuỗi CNAME. API đó không phải giải pháp CNAME uncloaking cho Chrome. [Chrome DNS](https://developer.chrome.com/docs/extensions/reference/api/dns), [dns.webidl của Chromium 153](https://github.com/chromium/chromium/blob/153.0.8010.53/extensions/common/api/dns.webidl). **Giới hạn regex đã quan sát trong snapshot bộ lọc ngày 6/9/2026:** Chrome 152 từ chối sáu ngoại lệ `allow` trong corpus regex `ublock-filters` (ID 47, 48, 49, 50, 52, 53) với lý do `memoryLimitExceeded`. Các ngoại lệ này áp dụng cho ảnh first-party trong phạm vi `pussyspace.com`/`pussyspace.net`. Đây là ID của snapshot hiện tại, không phải danh sách cố định cho các lần cập nhật sau. Chrome giới hạn mỗi regex sau biên dịch dưới 2 KB, riêng với quota số rule. Runtime kiểm tra đúng cờ phân biệt hoa/thường và yêu cầu capture của rule; cài unpacked không gỡ giới hạn đó. [Chrome regex limits](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest#regex-rules), [RegexOptions](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest#type-RegexOptions). +Cập nhật 24/9/2026: sáu ID trên chỉ là các ngoại lệ `allow`. Tính cả rule chặn, với sáu list mặc định Chrome 153 từ chối **80/254** regex rule stock (79 `memoryLimitExceeded`, 1 `syntaxError`). 174 regex còn lại cộng 826 session rule của strict-block dùng hết pool 1.000 regex dynamic + session. [Nghiên cứu](MV3-LIMITS-RESEARCH-2026-09-24.md#hằng-số-dnr-trên-chrome-153) đề xuất strict-block không dùng regex và chuyển regex stock sang static ruleset. + Nếu một lựa chọn stock list yêu cầu ngoại lệ regex mà Chrome từ chối, giao dịch báo lỗi kèm lý do native, ID và đoạn đầu biểu thức, rồi khôi phục snapshot native trước đó. Không bỏ ngoại lệ để áp dụng các rule chặn mới, cũng không thay nó bằng ngoại lệ URL rộng hơn. Vì vậy một số thay đổi lựa chọn list có thể bị từ chối cho đến khi corpus hoặc trình duyệt biểu diễn được đầy đủ ngoại lệ; đây là giới hạn đang có, không được ghi là thay đổi đã áp dụng thành công. ## Xác minh diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c8920f851f4bb..57637d6acf862 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,6 +4,8 @@ Roadmap dùng các nhóm **Now / Next / Later**, không hứa ngày phát hành. Thứ tự ưu tiên được cập nhật ngày 2026-09-06 qua [rà soát với uBlock Origin đầy đủ, AdGuard và Brave](MV3-CAPABILITY-AUDIT-2026-09-06.md), bổ sung hồ sơ [COMMUNITY-RESEARCH.md](COMMUNITY-RESEARCH.md). Issue upstream là tín hiệu thiết kế, không tự động trở thành cam kết của fork. +Ngày 2026-09-24, [nghiên cứu gỡ giới hạn MV3](MV3-LIMITS-RESEARCH-2026-09-24.md) phân loại 41 giới hạn so với uBO đầy đủ và đề xuất lộ trình 11 bước. Kết luận: Google Chrome không thể chạy mà không còn giới hạn nào, nhưng phần lớn khoảng trống thu hẹp được, trước ở bản Standard, sau đó ở bản Experimental; gỡ hết giới hạn chỉ có thể trên Firefox. **Bước 1 (báo cáo trung thực và sửa compiler) đã xong.** Các bước sau được xếp vào Next/Later bên dưới, vẫn theo quy tắc owner/design/test/ngân sách. + ## Now — nền tảng Power Edition - Hoàn tất identity uBlock Plus+ và build sideload có checksum/provenance. @@ -11,7 +13,8 @@ Thứ tự ưu tiên được cập nhật ngày 2026-09-06 qua [rà soát với - Filter Store schema v1, catalog đóng gói, issue form, validator, trust badge `verified|community` và tìm kiếm/lọc category/language. - Memory profiles `auto|balanced|low-memory`, local telemetry và cleanup an toàn. - [Bảng tra firewall và công cụ thử bản nháp](GITHUB-UPGRADES-2026-09-06.md): giải thích ô thắng, 1p/3p và Off theo yêu cầu; cùng evaluator cho compiler và lớp chặn tùy chọn. Không theo dõi nền hoặc gửi request thử ra mạng. -- Compiler phát mã lý do ổn định và số dòng cho network filter import bị từ chối/deferred; dashboard chi tiết và export báo cáo vẫn cần hoàn thiện. +- Compiler phát mã lý do ổn định và số dòng cho network filter import bị từ chối/deferred. Build stock cũng đếm filter không chuyển được sang DNR theo lý do trong `log.txt` và `ruleset-details.json`, và validator từ chối gói có số báo cáo sai; trước đây build ghi `Unsupported: 0` trong khi hơn một nghìn filter bị bỏ. Tooltip của mỗi list stock hiện số filter đã chuyển và số filter bị từ chối (gồm cả filter không hợp lệ mà uBO cổ điển cũng từ chối); dashboard chi tiết theo lý do và export báo cáo vẫn cần hoàn thiện. +- Hai compiler khớp nhau ở `$empty`, `$mp4`, bare `$removeparam` và resource redirect. Các mã lý do dùng chung được viết giống nhau (ví dụ `unsupported-redirect-rule`, `unsupported-requestheader`), nhưng domain và header vẫn khác mã. Runtime từ chối `$requestheader`, `@@…$uritransform` và ngoại lệ redirect thay vì sinh rule rộng hơn. `##^responseheader()` stock áp cho response của chính hostname trong filter và tôn trọng ngoại lệ. - Smart Popup Blocker theo opener/target/trusted gesture/burst, policy exact-host `Allow|Smart|Strict`, chẩn đoán đã redaction và backup/restore policy. - Compiled popup observer cho corpus stock `$popup` đóng gói và subset sandbox/imported `$popup`/`$popunder` đã classifier chấp nhận. Supported popup-only là accepted+routed dù không có DNR; unsupported condition giữ typed route `popup-compiler-required` với status `deferred`. Deferred allow có guard superset fail-open để không làm mất exception; context/budget không đầy đủ cũng fail open. Stock `$popunder` vẫn được ghi `omitted` vì DNR export không bảo toàn kind. - [Tự động cập nhật](AUTO-UPDATE.md): kiểm tra GitHub Releases định kỳ (có thể tắt, có policy managed `autoUpdate`), trình cập nhật Windows cài riêng với consent, xác minh checksum và danh tính gói, kiểm tra quyền thư mục, backup và rollback; workflow phát hành theo tag kèm provenance. Chữ ký release đã được hỗ trợ. Khi maintainer công bố khóa, chỉ bản cài mà installer đã ghim khóa (chạy `install-updater.cmd` từ một bản phát hành có khóa, hoặc installer một bước khi nhánh `main` đã có khóa) mới bắt buộc chữ ký; không có trust-on-first-use, nên bản cài hiện có vẫn chỉ kiểm checksum cho tới khi người dùng chạy lại installer. Hiện chưa có khóa nào. @@ -33,11 +36,20 @@ Các bổ sung ngày 6/9/2026: [firewall network với noop đúng nghĩa, logge - Khôi phục backup có journal/tiến độ cấp toàn profile; restore hiện tại vẫn tuần tự, chưa nguyên tử trên mọi thành phần. - Accessibility/i18n, import conflict UX và cảnh báo list không được chứng thực. - Upstream sync automation có human review cho compiler/security-sensitive conflicts. +- Các bước 2–5 của [nghiên cứu ngày 24/9](MV3-LIMITS-RESEARCH-2026-09-24.md#lộ-trình-11-bước), đều làm được trong bản DNR: + - strict-block bằng redirect `extensionPath` không dùng regex, và chuyển regex stock sang static ruleset để giải phóng pool 1.000 regex dynamic + session; + - công tắc theo site no-remote-fonts, no-csp-reports, no-scripting và no-large-media (hai rule `Content-Length`), cùng `$inline-script`/`$inline-font` bằng CSP; + - một compiler cho stock, list nhập thêm và bộ lọc cá nhân; + - logger truy về list/dòng nguồn, picker có ứng viên network filter, `$popup` có điều kiện party, stock `$popunder` và `$ipaddress` cho `main_frame`. +- Việc còn mở sau bước 1: báo cáo (hoặc từ chối) filter có hostname entity/regex bị phủ định, như `domain=~example.*`, hiện bị bỏ im lặng và làm rule rộng hơn (187 filter stock trong bản chụp ngày 23/9/2026); trang xem filter bị từ chối theo lý do; đếm hostname entity bị bỏ khi filter chỉ chuyển một phần; và thêm `##^responseheader()` cho list nhập thêm. ## Later — capability tùy chọn sau RFC +- Experimental: engine mạng của uBO đầy đủ (`static-net-filtering.js`) chạy trong một listener `webRequest` đồng bộ, rồi firewall động, URL rule và công tắc hostname từ `src/js`. DNR vẫn là lớp nền: Chrome có thể được mở mà không có tham số allowlist, trạng thái người dùng vẫn đọc bất đồng bộ, và lần khởi động nguội của cả trình duyệt chưa được đo. Engine sẵn sàng sau 60–95 ms mỗi lần worker khởi động (số đo trên một máy). Bước 6–7 của nghiên cứu. +- Bản Firefox của Plus+ với engine đầy đủ: con đường duy nhất gỡ cả lọc body dạng stream, CNAME chính xác và quyết định bất đồng bộ. Bước 8. - Managed Enterprise build/adapter và policy deployment guide. -- Native Companion/Power Mode (DNS/proxy/diagnostics) proof-of-concept, dùng lại versioned Native Messaging IPC của trình cập nhật. +- Native Companion/Power Mode (DNS/proxy/diagnostics) proof-of-concept, dùng lại versioned Native Messaging IPC của trình cập nhật. Companion CNAME chỉ dùng resolver của hệ điều hành, không dùng DoH. +- R&D bản riêng dùng `chrome.debugger` cho HTML filtering và `$replace`, giữ thanh cảnh báo debugger; tỷ lệ giá trị/công sức thấp nhất. - Custom Chromium RFC/artifact riêng với patch audit, profile riêng và support matrix; không dùng custom capability để quảng cáo Google Chrome build. - Nghiên cứu DNS-aware diagnostics/local proxy chỉ sau privacy/security/performance review. - Federated catalog metadata chỉ khi có signature, provenance, revocation và UX trust rõ; không tải code. @@ -46,6 +58,7 @@ Các bổ sung ngày 6/9/2026: [firewall network với noop đúng nghĩa, logge - tuyên bố tương thích MV2 100% khi Chrome MV3 không có API; - bypass sandbox/quota bằng hack không được trình duyệt hỗ trợ; +- mặc định hoặc âm thầm ẩn cảnh báo của trình duyệt (ví dụ thêm `--silent-debugger-extension-api`, vốn ẩn cảnh báo debugger cho mọi extension), hoặc cài proxy chặn TLS/chứng chỉ gốc để sửa body; - remote executable JavaScript/Wasm, remote scriptlet/module hoặc plugin từ URL/Git repo; filter/catalog HTTPS chỉ được xử lý như data, không được thực thi; - telemetry/browsing-history upload mặc định; - native companion tự cài/chạy mà không có consent; diff --git a/platform/mv3/extension/_locales/de/messages.json b/platform/mv3/extension/_locales/de/messages.json index 1581bf919cd43..0b61a9077f32a 100644 --- a/platform/mv3/extension/_locales/de/messages.json +++ b/platform/mv3/extension/_locales/de/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} Regeln, umgewandelt aus {{filterCount}} Netzwerkfiltern", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} Filter konnten nicht in Chrome-Regeln umgewandelt werden und werden nicht angewendet", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — Dashboard", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json index 91c69fb9c77ef..8892507cff766 100644 --- a/platform/mv3/extension/_locales/en/messages.json +++ b/platform/mv3/extension/_locales/en/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} rules, converted from {{filterCount}} network filters", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} filters could not be converted into Chrome rules and are not applied", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — Dashboard", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/es/messages.json b/platform/mv3/extension/_locales/es/messages.json index b7026c66dc579..610dea0a8e6d8 100644 --- a/platform/mv3/extension/_locales/es/messages.json +++ b/platform/mv3/extension/_locales/es/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} reglas, convertidas desde {{filterCount}} filtros de red", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} filtros no se pudieron convertir en reglas de Chrome y no se aplican", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — Panel de control", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/fr/messages.json b/platform/mv3/extension/_locales/fr/messages.json index d7b186b70282c..06bed92962e85 100644 --- a/platform/mv3/extension/_locales/fr/messages.json +++ b/platform/mv3/extension/_locales/fr/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} règles converties depuis {{filterCount}} filtres de réseau", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} filtres n’ont pas pu être convertis en règles Chrome et ne sont pas appliqués", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — Tableau de bord", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/ja/messages.json b/platform/mv3/extension/_locales/ja/messages.json index c6853490ffbcf..7f8d643dea01a 100644 --- a/platform/mv3/extension/_locales/ja/messages.json +++ b/platform/mv3/extension/_locales/ja/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} 件のルール ({{filterCount}} 件のネットワークフィルターから変換)", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} 件のフィルターは Chrome のルールに変換できなかったため適用されません", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — ダッシュボード", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/ko/messages.json b/platform/mv3/extension/_locales/ko/messages.json index c4be385f40280..804176f3a57bd 100644 --- a/platform/mv3/extension/_locales/ko/messages.json +++ b/platform/mv3/extension/_locales/ko/messages.json @@ -11,6 +11,10 @@ "message": "네트워크 필터 {{filterCount}}개로부터 변환된 규칙 {{ruleCount}}개", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "필터 {{count}}개는 Chrome 규칙으로 변환할 수 없어 적용되지 않습니다", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — 대시보드", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/ru/messages.json b/platform/mv3/extension/_locales/ru/messages.json index 18407bded5d99..0912e934a06e8 100644 --- a/platform/mv3/extension/_locales/ru/messages.json +++ b/platform/mv3/extension/_locales/ru/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} правил, преобразованных из {{filterCount}} сетевых фильтров", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "Фильтры, которые не удалось преобразовать в правила Chrome и которые не применяются: {{count}}", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — Панель управления", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/vi/messages.json b/platform/mv3/extension/_locales/vi/messages.json index 1f14f1ca54cbc..d2c5b3402818e 100644 --- a/platform/mv3/extension/_locales/vi/messages.json +++ b/platform/mv3/extension/_locales/vi/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} quy tắc, được chuyển đổi từ {{filterCount}} bộ lọc mạng", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} bộ lọc không chuyển được thành quy tắc Chrome nên không được áp dụng", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — Bảng điều khiển", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/zh_CN/messages.json b/platform/mv3/extension/_locales/zh_CN/messages.json index 53367fba8ef01..962f3f151bf7e 100644 --- a/platform/mv3/extension/_locales/zh_CN/messages.json +++ b/platform/mv3/extension/_locales/zh_CN/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} 条规则,转换自 {{filterCount}} 条网络规则", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} 条过滤规则无法转换为 Chrome 规则,因此未应用", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — 控制面板", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/_locales/zh_TW/messages.json b/platform/mv3/extension/_locales/zh_TW/messages.json index 462ad89fe59db..4557581393cb5 100644 --- a/platform/mv3/extension/_locales/zh_TW/messages.json +++ b/platform/mv3/extension/_locales/zh_TW/messages.json @@ -11,6 +11,10 @@ "message": "{{ruleCount}} 條規則,由 {{filterCount}} 條網路過濾規則轉換而來", "description": "Appears aside each filter list in the _3rd-party filters_ pane" }, + "perRulesetUnsupported": { + "message": "{{count}} 條過濾規則無法轉換為 Chrome 規則,因此未套用", + "description": "Second line of the tooltip aside each filter list in the _3rd-party filters_ pane: filters (unsupported by DNR, or invalid) that could not be converted to DNR rules" + }, "dashboardName": { "message": "uBlock Plus+ — 控制台", "description": "English: uBlock Plus+ — Dashboard" diff --git a/platform/mv3/extension/js/compiled-cache.js b/platform/mv3/extension/js/compiled-cache.js index e7adef94ab6f5..dc177d983ac33 100644 --- a/platform/mv3/extension/js/compiled-cache.js +++ b/platform/mv3/extension/js/compiled-cache.js @@ -19,7 +19,7 @@ import { // Increment when compiler semantics change; older envelopes must be rebuilt // from source instead of reusing output which lost exceptions or metadata. -export const COMPILED_FILTERS_REVISION = 3; +export const COMPILED_FILTERS_REVISION = 4; function isStats(value, fields) { if ( typeof value !== 'object' || value === null ) { return false; } diff --git a/platform/mv3/extension/js/filter-lists.js b/platform/mv3/extension/js/filter-lists.js index 5bf25e154659e..e9627a9250e1f 100644 --- a/platform/mv3/extension/js/filter-lists.js +++ b/platform/mv3/extension/js/filter-lists.js @@ -94,13 +94,23 @@ function updateNodes(listEntries) { /******************************************************************************/ +export function rulesetStatsFromDetails(rulesetDetails) { + const { rules, filters } = rulesetDetails; + const ruleCount = rules.plain + rules.regex; + // Stock rulesets: `accepted` also counts the filters which could not be + // converted, and counts a filter once per type. Imported rulesets do not + // count the filters they reject as `accepted`. + const filterCount = filters.converted ?? filters.accepted; + // Stock rulesets count the network filters DNR cannot express. Imported + // lists report theirs where they are imported. + const unsupportedCount = Number.isSafeInteger(rules.rejected) ? rules.rejected : 0; + return { ruleCount, filterCount, unsupportedCount }; +} + function rulesetStats(rulesetId) { const rulesetDetails = rulesetMap.get(rulesetId); if ( rulesetDetails === undefined ) { return; } - const { rules, filters } = rulesetDetails; - const ruleCount = rules.plain + rules.regex; - const filterCount = filters.accepted; - return { ruleCount, filterCount }; + return rulesetStatsFromDetails(rulesetDetails); } /******************************************************************************/ @@ -141,6 +151,7 @@ export async function renderFilterLists(incremental = false) { }); const listStatsTemplate = i18n$('perRulesetStats'); + const unsupportedTemplate = i18n$('perRulesetUnsupported'); const beforeListEntries = new Set(qsa$('#lists .listEntry:not([data-role="root"])')); const initializeListEntry = (ruleset, listEntry) => { @@ -166,6 +177,10 @@ export async function renderFilterLists(incremental = false) { listEntry.title = listStatsTemplate .replace('{{ruleCount}}', renderNumber(stats.ruleCount)) .replace('{{filterCount}}', renderNumber(stats.filterCount)); + if ( stats.unsupportedCount !== 0 ) { + listEntry.title += '\n' + unsupportedTemplate + .replace('{{count}}', renderNumber(stats.unsupportedCount)); + } if ( isImported ) { return; } const fromAdmin = isAdminRuleset(ruleset.id); dom.cl.toggle(listEntry, 'fromAdmin', fromAdmin); diff --git a/platform/mv3/extension/js/ubo-parser.js b/platform/mv3/extension/js/ubo-parser.js index fe850b1ee7158..cd0e2da17d26c 100644 --- a/platform/mv3/extension/js/ubo-parser.js +++ b/platform/mv3/extension/js/ubo-parser.js @@ -50,9 +50,17 @@ const safeResourceTypes = [ /******************************************************************************/ +// Same table as the stock build (make-rulesets.js). click2load.html needs +// the MV2 messaging layer and URL parameters which a DNR extensionPath +// cannot carry, so the stock build never redirects to it either. +const unsupportedRedirectResources = new Set([ + 'click2load.html', +]); + const validRedirectResources = (( ) => { const out = new Map(); for ( const [ name, resource ] of redirectResourceMap ) { + if ( unsupportedRedirectResources.has(name) ) { continue; } out.set(name, name); if ( resource.alias === undefined ) { continue; } if ( typeof resource.alias === 'string' ) { @@ -418,41 +426,73 @@ export function attachStockBadfilterResiduals(rules) { /******************************************************************************/ +const isSameOrSubdomain = (hn, parent) => + hn === parent || hn.endsWith(`.${parent}`); + +// Hostnames matched by both DNR domain lists. A list entry also matches its +// subdomains, so of two related entries the more specific one is in both; +// an entry under another one of the result is redundant. +function intersectHostnames(a, b) { + const both = new Set(); + for ( const x of a ) { + for ( const y of b ) { + if ( isSameOrSubdomain(x, y) ) { + both.add(x); + } else if ( isSameOrSubdomain(y, x) ) { + both.add(y); + } + } + } + const out = Array.from(both); + return out.filter(hn => out.every(other => + other === hn || isSameOrSubdomain(hn, other) === false + )).sort(); +} + +// For a navigation, uBO matches `domain=` against the navigated hostname. +// A request-side list (hostname pattern, `to=`) must match that hostname too: +// DNR ORs the entries of one list, so the two lists are intersected. Returns +// false, leaving the rule untouched, when no hostname is in both. function convertInitiatorDomainsToRequestDomains(rule) { - if ( rule.condition.initiatorDomains ) { - rule.condition.requestDomains ??= []; - rule.condition.requestDomains = [ - ...rule.condition.requestDomains, - ...rule.condition.initiatorDomains, - ]; - delete rule.condition.initiatorDomains; + const { condition } = rule; + if ( condition.initiatorDomains ) { + const requestDomains = condition.requestDomains === undefined + ? [ ...condition.initiatorDomains ] + : intersectHostnames(condition.requestDomains, condition.initiatorDomains); + if ( requestDomains.length === 0 ) { return false; } + condition.requestDomains = requestDomains; + delete condition.initiatorDomains; } - if ( rule.condition.excludedInitiatorDomains ) { - rule.condition.excludedRequestDomains ??= []; - rule.condition.excludedRequestDomains = [ - ...rule.condition.excludedRequestDomains, - ...rule.condition.excludedInitiatorDomains, + if ( condition.excludedInitiatorDomains ) { + condition.excludedRequestDomains ??= []; + condition.excludedRequestDomains = [ + ...condition.excludedRequestDomains, + ...condition.excludedInitiatorDomains, ]; - delete rule.condition.excludedInitiatorDomains; + delete condition.excludedInitiatorDomains; } + return true; } /******************************************************************************/ // https://github.com/uBlockOrigin/uBOL-home/discussions/736 +// Returns false when rule0, a main_frame-only rule, can match no navigation: +// the caller must then discard it. export function expandRemoveparamsRule(rule0, out) { - if ( Boolean(rule0.condition.resourceTypes?.includes('main_frame')) === false ) { return; } - if ( rule0.condition.initiatorDomains === undefined ) { return; } + if ( Boolean(rule0.condition.resourceTypes?.includes('main_frame')) === false ) { return true; } + if ( rule0.condition.initiatorDomains === undefined ) { return true; } if ( rule0.condition.resourceTypes.length === 1 ) { - convertInitiatorDomainsToRequestDomains(rule0); - return; + return convertInitiatorDomainsToRequestDomains(rule0); } const rule1 = structuredClone(rule0); rule0.condition.resourceTypes = rule0.condition.resourceTypes.filter(a => a !== 'main_frame'); rule1.condition.resourceTypes = [ 'main_frame' ]; - convertInitiatorDomainsToRequestDomains(rule1); - out.push(rule1); + if ( convertInitiatorDomainsToRequestDomains(rule1) ) { + out.push(rule1); + } + return true; } /******************************************************************************/ @@ -520,11 +560,12 @@ export function validateRules(rules, rejections) { /******************************************************************************/ // Priority: -// Removeparam: 1-4 +// Removeparam: 1-4 (important: 2) // Block: 10 (default priority) // Redirect: 11-19 // Excepted redirect: 21-29 // Allow: 30 +// Csp, permissions important: 31 // Block important: 40 // Redirect important: 41-49 @@ -633,6 +674,25 @@ export function parseNetworkFilter(parser, details = {}, out = []) { let subpriority = 0; let isImportant = false; + // `redirect=`, `empty` and `mp4` are the same modifier in classic uBO. + // An exception only cancels the redirection and leaves any block in + // place; DNR cannot express that without blocking by itself. + const processRedirect = token => { + if ( isException ) { return 'unsupported-redirect-exception'; } + if ( rule.action.type !== 'block' ) { return 'redirect-action-conflict'; } + const match = /:(\d+)$/.exec(token); + if ( match ) { + subpriority = Math.min(parseInt(match[1], 10) || 0, 8); + token = token.slice(0, match.index); + } + const resource = validRedirectResources.get(token); + if ( resource === undefined ) { return 'unsupported-redirect-resource'; } + rule.action.type = 'redirect'; + rule.action.redirect = { + extensionPath: `/web_accessible_resources/${resource}`, + }; + }; + for ( const type of parser.getNodeTypes() ) { switch ( type ) { case sfp.NODE_TYPE_NET_OPTION_NAME_1P: @@ -658,6 +718,10 @@ export function parseNetworkFilter(parser, details = {}, out = []) { return reject('unsupported-redirect-rule'); case sfp.NODE_TYPE_NET_OPTION_NAME_REPLACE: return reject('unsupported-replace'); + // DNR has no request-header condition: without the option the + // filter would block or allow every matching request. + case sfp.NODE_TYPE_NET_OPTION_NAME_REQUESTHEADER: + return reject('unsupported-requestheader'); case sfp.NODE_TYPE_NET_OPTION_NAME_SHIDE: return reject('unsupported-shide'); case sfp.NODE_TYPE_NET_OPTION_NAME_URLSKIP: @@ -725,6 +789,11 @@ export function parseNetworkFilter(parser, details = {}, out = []) { case sfp.NODE_TYPE_NET_OPTION_NAME_DOC: processResourceType('main_frame', type); break; + case sfp.NODE_TYPE_NET_OPTION_NAME_EMPTY: { + const reasonCode = processRedirect('empty'); + if ( reasonCode ) { return reject(reasonCode); } + break; + } case sfp.NODE_TYPE_NET_OPTION_NAME_FONT: processResourceType('font', type); break; @@ -796,6 +865,12 @@ export function parseNetworkFilter(parser, details = {}, out = []) { } break; } + case sfp.NODE_TYPE_NET_OPTION_NAME_MP4: { + const reasonCode = processRedirect('noopmp4-1s'); + if ( reasonCode ) { return reject(reasonCode); } + processResourceType('media', type); + break; + } case sfp.NODE_TYPE_NET_OPTION_NAME_OBJECT: processResourceType('object', type); break; @@ -807,10 +882,12 @@ export function parseNetworkFilter(parser, details = {}, out = []) { return reject('response-header-action-conflict'); } rule.action.type = 'modifyHeaders'; + // `|` separates policies in filter syntax; left in place it + // makes the whole header unparseable, so nothing is enforced. rule.action.responseHeaders = [ { header: 'permissions-policy', operation: 'append', - value: parser.getNetOptionValue(type), + value: parser.getNetOptionValue(type).split('|').join(', '), } ]; defaultResourceTypes.add('main_frame'); defaultResourceTypes.add('sub_frame'); @@ -822,26 +899,18 @@ export function parseNetworkFilter(parser, details = {}, out = []) { case sfp.NODE_TYPE_NET_OPTION_NAME_REASON: break; case sfp.NODE_TYPE_NET_OPTION_NAME_REDIRECT: { - if ( rule.action.type !== 'block' ) { - return reject('redirect-action-conflict'); - } - let value = parser.getNetOptionValue(type); - const match = /:(\d+)$/.exec(value); - if ( match ) { - subpriority = Math.min(parseInt(match[1], 10) || 0, 8); - value = value.slice(0, match.index); - } - if ( validRedirectResources.has(value) === false ) { - return reject('unsupported-redirect-resource'); - } - rule.action.type = 'redirect'; - rule.action.redirect = { - extensionPath: `/web_accessible_resources/${validRedirectResources.get(value)}`, - }; + const reasonCode = processRedirect(parser.getNetOptionValue(type)); + if ( reasonCode ) { return reject(reasonCode); } break; } case sfp.NODE_TYPE_NET_OPTION_NAME_REMOVEPARAM: { - const details = sfp.parseQueryPruneValue(parser.getNetOptionValue(type)); + // A bare value, or its legacy `|` spelling, removes the whole + // query in classic uBO. DNR clears it with an empty `query`; + // an empty `removeParams` is accepted but removes nothing. + const value = parser.getNetOptionValue(type); + const details = value.trim() === '|' + ? { all: true } + : sfp.parseQueryPruneValue(value); if ( details.bad ) { return reject('invalid-removeparam'); } if ( details.not ) { return reject('unsupported-removeparam-negated'); @@ -849,19 +918,19 @@ export function parseNetworkFilter(parser, details = {}, out = []) { if ( details.re ) { return reject('unsupported-removeparam-regex'); } - const removeParams = []; - if ( details.name ) { - removeParams.push(details.name); + rule.action.type = 'redirect'; + if ( details.all ) { + rule.action.redirect = { transform: { query: '' } }; + } else { if ( rule.condition.urlFilter === undefined ) { if ( rule.condition.regexFilter === undefined ) { rule.condition.urlFilter = `^${details.name}=`; } } + rule.action.redirect = { + transform: { queryTransform: { removeParams: [ details.name ] } } + }; } - rule.action.type = 'redirect'; - rule.action.redirect = { - transform: { queryTransform: { removeParams } } - }; defaultResourceTypes.add('main_frame'); defaultResourceTypes.add('sub_frame'); defaultResourceTypes.add('xmlhttprequest'); @@ -911,6 +980,12 @@ export function parseNetworkFilter(parser, details = {}, out = []) { break; } case sfp.NODE_TYPE_NET_OPTION_NAME_URLTRANSFORM: { + // An exception only cancels a transform. Compiled like one, it + // would become a redirect, and exceptions skip the parser's + // trusted-source check. + if ( isException ) { + return reject('unsupported-urltransform-exception'); + } const parsed = sfp.parseReplaceByRegexValue(parser.getNetOptionValue(type)); if ( parsed === undefined ) { return reject('invalid-urltransform'); @@ -941,6 +1016,14 @@ export function parseNetworkFilter(parser, details = {}, out = []) { rule.condition.excludedInitiatorDomains = Array.from(excludedInitiatorDomains).sort(); } if ( requestDomains.size !== 0 ) { + // `to=` narrows a hostname pattern: both must match. Overwriting + // the pattern's requestDomains would block the whole `to=` list. + if ( parser.isHostnamePattern() ) { + const anchor = `||${pattern}^`; + rule.condition.urlFilter = rule.condition.urlFilter === undefined + ? anchor + : `${anchor}*${rule.condition.urlFilter}`; + } rule.condition.requestDomains = Array.from(requestDomains).sort(); } if ( excludedRequestDomains.size !== 0 ) { @@ -1043,7 +1126,7 @@ export function parseNetworkFilter(parser, details = {}, out = []) { } else { priority = (isImportant ? 41 : 11) + subpriority; } - } else if ( rule.action.redirect.transform?.queryTransform?.removeParams ) { + } else if ( rule.action.redirect.transform ) { if ( isException ) { rule.action.type = 'allow'; delete rule.action.redirect; @@ -1056,12 +1139,24 @@ export function parseNetworkFilter(parser, details = {}, out = []) { delete rule.action.responseHeaders; } } + // `important` lifts a modifier above its own priority-1 exceptions. Header + // rules (csp, permissions) are matched apart from block rules, so like the + // stock build they also pass the priority-30 exceptions. A transform + // (removeparam, uritransform) stays below the blocks: DNR only applies the + // highest-priority matching rule, and a transform which leaves the URL + // unchanged does nothing, which would cancel a block that classic uBO + // applies before any removeparam. + if ( isImportant && priority === 1 && rule.action.type !== 'allow' ) { + priority = rule.action.type === 'modifyHeaders' ? 31 : 2; + } if ( priority !== 1 ) { rule.priority = priority; } const parsedRules = [ rule ]; - if ( rule.action.redirect?.transform?.queryTransform?.removeParams ) { - expandRemoveparamsRule(rule, parsedRules); + if ( rule.action.redirect?.transform ) { + if ( expandRemoveparamsRule(rule, parsedRules) === false ) { + parsedRules.length = 0; + } } for ( const parsedRule of parsedRules ) { const validation = validateRule(parsedRule); @@ -1234,7 +1329,7 @@ export class NetworkFilterCompiler { // rule. Merged with a sibling unit, validation already drops the // entity; alone, after the sibling was badfiltered, it would fail // validation and abort the whole compilation. - const rules = hostname === undefined || isNotEntity(hostname) + let rules = hostname === undefined || isNotEntity(hostname) ? structuredClone(lineRules) : []; const popupFilters = structuredClone(linePopupFilters); @@ -1246,9 +1341,14 @@ export class NetworkFilterCompiler { parser.getNodeTypes().includes(sfp.NODE_TYPE_NET_OPTION_NAME_REMOVEPARAM) && item.condition.resourceTypes?.length === 1 && item.condition.resourceTypes[0] === 'main_frame' ) { - item.condition.requestDomains = [ hostname ]; + // Already the `domain=` list intersected with any + // `to=` list: keep only this unit's share of it. + item.condition.requestDomains = intersectHostnames( + item.condition.requestDomains, [ hostname ] + ); } } + rules = rules.filter(rule => rule.condition.requestDomains?.length !== 0); } this.networkUnits.push({ key, dnrRules: rules, popupFilters }); } diff --git a/platform/mv3/make-rulesets.js b/platform/mv3/make-rulesets.js index 5dfe236767e97..9d3a12eaf692a 100644 --- a/platform/mv3/make-rulesets.js +++ b/platform/mv3/make-rulesets.js @@ -46,6 +46,8 @@ import { randomBytes, } from 'crypto'; import { + dnrConvertedFilterCount, + dnrErrorSummary, dnrRulesetFromRawLists, mergeRules, } from './js/static-dnr-filtering.js'; @@ -212,6 +214,8 @@ const scriptletStats = new Map(); const scriptletExceptionStats = new Map(); const genericDetails = new Map(); const requiredRedirectResources = new Set(); +const rejectedReasonTotals = new Map(); +let rejectedTotal = 0; let networkBad = new Set(); /******************************************************************************/ @@ -229,6 +233,55 @@ log(`Reusing downloaded lists younger than ${cacheMaxAgeDays} day(s)`, false); /******************************************************************************/ +// Every resource a `redirect=` filter can name is packaged and declared +// web-accessible, not only those used by the stock lists: the runtime +// compiler accepts all the names in redirect-resources.js, and Chrome fails +// a DNR redirect to an extension path which is missing or not web-accessible. +// Resources which cannot be packaged are listed as unavailable in +// rulesets/redirect-resources.json, which the runtime can read. + +const unpackagedRedirectResources = new Map([ + // uBO's click-to-load page needs its own scripts and the URL of the + // blocked frame as a parameter, which a DNR extensionPath cannot carry. + [ 'click2load.html', 'requires-click-to-load-page' ], +]); + +const redirectResources = await (async ( ) => { + const packaged = []; + const extensionPaths = []; + const unavailable = []; + for ( const [ fname, details ] of redirectResourcesMap ) { + const tokens = [ fname ]; + if ( typeof details.alias === 'string' ) { + tokens.push(details.alias); + } else if ( Array.isArray(details.alias) ) { + tokens.push(...details.alias); + } + const reason = unpackagedRedirectResources.get(fname); + if ( reason !== undefined ) { + for ( const token of tokens ) { + unavailable.push([ token, reason ]); + } + continue; + } + // Stock filters using a missing resource would be dropped, and a + // redirect to it from a runtime-compiled filter would fail. + const stat = await fs.stat(`./web_accessible_resources/${fname}`) + .catch(( ) => { }); + if ( stat?.isFile() !== true ) { + throw new Error(`Redirect resource not found: ${fname}`); + } + const path = `/web_accessible_resources/${fname}`; + packaged.push(path.slice(1)); + for ( const token of tokens ) { + extensionPaths.push([ token, path ]); + } + } + return { packaged, extensionPaths, unavailable }; +})(); + +/******************************************************************************/ + const restrSeparator = '[^%.0-9a-z_-]'; const rePatternFromUrlFilter = s => { @@ -518,12 +571,18 @@ function isStrictBlockRule(rule) { /******************************************************************************/ +// Rules which could not be converted (`_error`) are set aside so that they +// are counted and reported, never emitted. function splitDnrRules(rules) { const dnrRules = []; const popupRules = []; const sbRules = []; + const rejectedRules = []; for ( const rule of rules ) { - if ( rule._error ) { continue; } + if ( rule._error ) { + rejectedRules.push(rule); + continue; + } const nottypes = rule.condition?.excludedResourceTypes; if ( nottypes ) { rule.condition.excludedResourceTypes = nottypes.filter(a => @@ -556,12 +615,12 @@ function splitDnrRules(rules) { } dnrRules.push(rule); } - return { dnrRules, sbRules, popupRules }; + return { dnrRules, sbRules, popupRules, rejectedRules }; } /******************************************************************************/ -async function processDnrRules(assetDetails, network, dnrRules) { +async function processDnrRules(assetDetails, network, dnrRules, rejectedRules) { log(`Input filter count: ${network.filterCount}`); log(`\tAccepted filter count: ${network.acceptedFilterCount}`); log(`\tRejected filter count: ${network.rejectedFilterCount}`); @@ -611,12 +670,21 @@ async function processDnrRules(assetDetails, network, dnrRules) { ); }); - // Patch removeParams rules as needed + // Patch removeParams rules as needed. A main_frame-only rule which can + // match no navigation (uBO matches `domain=` against the navigated + // hostname) is discarded, as the runtime compiler does. + const unmatchableRules = new Set(); for ( const rule of staticRules ) { if ( rule.action.redirect?.transform?.queryTransform?.removeParams ) { - expandRemoveparamsRule(rule, staticRules); + if ( expandRemoveparamsRule(rule, staticRules) === false ) { + unmatchableRules.add(rule); + } } } + for ( let i = staticRules.length - 1; i >= 0; i-- ) { + if ( unmatchableRules.has(staticRules[i]) === false ) { continue; } + staticRules.splice(i, 1); + } // Minimize rulesets attachStockBadfilterResiduals(staticRules); @@ -664,11 +732,20 @@ async function processDnrRules(assetDetails, network, dnrRules) { } log(`\turlskip=: ${urlskips.size}`); - const bad = dnrRules.filter(rule => - isUnsupported(rule) - ); - log(`\tUnsupported: ${bad.length}`); - log(bad.map(rule => rule._error.map(v => `\t\t${v}`)).join('\n'), true); + const bad = [ + ...rejectedRules, + ...dnrRules.filter(rule => isUnsupported(rule)), + ]; + const rejected = dnrErrorSummary(bad); + log(`\tUnsupported: ${rejected.count}`); + for ( const [ reason, count ] of Object.entries(rejected.reasons) ) { + log(`\t\t${reason}: ${count}`); + rejectedReasonTotals.set(reason, + (rejectedReasonTotals.get(reason) ?? 0) + count + ); + } + rejectedTotal += rejected.count; + log(bad.flatMap(rule => rule._error.map(v => `\t\t${v}`)).join('\n'), true); const staticJSON = toJSONRuleset(minimizedStaticRuleset); writeFile(`${rulesetDir}/main/${assetDetails.id}.json`, staticJSON); @@ -713,7 +790,8 @@ async function processDnrRules(assetDetails, network, dnrRules) { total: minimizedStaticRuleset.length + minimizedRegexRuleset.length, plain: minimizedStaticRuleset.length, regex: minimizedRegexRuleset.length, - rejected: bad.length, + rejected: rejected.count, + rejectedReasons: rejected.count !== 0 ? rejected.reasons : undefined, urlskip: urlskips.size || undefined, }; } @@ -1099,24 +1177,7 @@ async function rulesetFromURLs(assetDetails) { if ( assetDetails.text === '' ) { return; } - const excludedResources = new Set([ - 'click2load.html', - ]); - const extensionPaths = []; - for ( const [ fname, details ] of redirectResourcesMap ) { - if ( excludedResources.has(fname) ) { continue; } - const path = `/web_accessible_resources/${fname}`; - extensionPaths.push([ fname, path ]); - if ( details.alias === undefined ) { continue; } - if ( typeof details.alias === 'string' ) { - extensionPaths.push([ details.alias, path ]); - continue; - } - if ( Array.isArray(details.alias) === false ) { continue; } - for ( const alias of details.alias ) { - extensionPaths.push([ alias, path ]); - } - } + const { extensionPaths } = redirectResources; const results = await dnrRulesetFromRawLists( [ { name: assetDetails.id, text: assetDetails.text } ], @@ -1137,7 +1198,13 @@ async function rulesetFromURLs(assetDetails) { writeFile(`${rulesetDir}/debug/${assetDetails.id}.all.json`, JSON.stringify(results.network.ruleset, null, 2) ); - const { dnrRules, sbRules, popupRules } = splitDnrRules(results.network.ruleset) + const convertedFilterCount = dnrConvertedFilterCount(results.network.ruleset); + const { + dnrRules, + sbRules, + popupRules, + rejectedRules, + } = splitDnrRules(results.network.ruleset); assetDetails.badfilterKeys = results.networkBadfilterKeys; assetDetails.badfilterDeferredKeys = [ ...sbRules, ...popupRules ] .flatMap(rule => rule._sourceKeys ?? []); @@ -1151,7 +1218,9 @@ async function rulesetFromURLs(assetDetails) { JSON.stringify(popupRules, null, 2) ); - const netStats = await processDnrRules(assetDetails, results.network, dnrRules); + const netStats = await processDnrRules(assetDetails, results.network, + dnrRules, rejectedRules + ); const popupStats = await processPopupRules(assetDetails, popupRules); const strictBlocked = new Map(); @@ -1232,9 +1301,14 @@ async function rulesetFromURLs(assetDetails) { tags: assetDetails.tags, homeURL: assetDetails.homeURL, filters: { + // Compiled filters (one per type, as classic uBO counts them), + // `rejected` being those removed by `badfilter` total: results.network.filterCount, accepted: results.network.acceptedFilterCount, rejected: results.network.rejectedFilterCount, + // Source network filters converted into DNR rules, the unit of + // `rules.rejected` and of the imported lists' `accepted` + converted: convertedFilterCount, }, rules: { total: netStats.total, @@ -1246,7 +1320,10 @@ async function rulesetFromURLs(assetDetails) { strictblock: strictBlocked.size || undefined, urlskip: netStats.urlskip, discarded: netStats.discarded, + // Source filters which could not be converted into DNR rules, and + // how many per reason (see dnrErrorSummary()) rejected: netStats.rejected, + rejectedReasons: netStats.rejectedReasons, }, css: { generic: genericCosmeticStats, @@ -1311,10 +1388,33 @@ async function main() { `${JSON.stringify(genericDetails, jsonSetMapReplacer, 1)}\n` ); - // Copy required redirect resources - for ( const path of requiredRedirectResources ) { + // Copy all packageable redirect resources, stock lists use only some of + // them (requiredRedirectResources) + const packagedRedirectResources = new Set([ + ...redirectResources.packaged, + ...requiredRedirectResources, + ]); + for ( const path of packagedRedirectResources ) { copyFile(`./${path}`, `${outputDir}/${path}`); } + writeFile(`${rulesetDir}/redirect-resources.json`, `${JSON.stringify({ + schemaVersion: 1, + resources: Object.fromEntries(redirectResources.extensionPaths), + unavailable: Object.fromEntries(redirectResources.unavailable), + }, null, 1)}\n`); + const unavailableRedirects = redirectResources.unavailable + .map(([ token, reason ]) => `${token} (${reason})`) + .join(', ') || 'none'; + log(`Redirect resources: ${redirectResources.packaged.length} packaged, ` + + `${requiredRedirectResources.size} used by stock rulesets, ` + + `unavailable: ${unavailableRedirects}`, false); + + log(`Unsupported filters in ${rulesetDetails.length} rulesets: ${rejectedTotal}`, false); + for ( const [ reason, count ] of Array.from(rejectedReasonTotals).sort( + (a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1) + ) ) { + log(`\t${reason}: ${count}`, false); + } await Promise.all(writeOps); @@ -1331,7 +1431,7 @@ async function main() { // Patch web_accessible_resources key manifest.web_accessible_resources = manifest.web_accessible_resources || []; const web_accessible_resources = { - resources: Array.from(requiredRedirectResources).map(path => `${path}`), + resources: Array.from(packagedRedirectResources).sort(), matches: [ '' ], }; if ( env.includes('chromium') && env.includes('safari') === false ) { diff --git a/src/js/static-dnr-filtering.js b/src/js/static-dnr-filtering.js index d001ab0eef475..05454cebf0c01 100644 --- a/src/js/static-dnr-filtering.js +++ b/src/js/static-dnr-filtering.js @@ -97,6 +97,177 @@ function addGenericCosmeticFilter(context, selector, isException) { /******************************************************************************/ +// Response header filtering +// +// Classic uBO (httpheader-filtering.js) removes the header from every +// response whose own hostname (or a parent domain, an entity or a regex) +// matches a filter, whatever the type of the request. Exceptions are keyed +// on header name and hostname, not on the filter: +// - `~hn` in `a.com,~hn##^responseheader(x)` excepts `hn` for every `x` filter +// - `hn#@#^responseheader(x)` excepts `hn` for every `x` filter +// - `#@#^responseheader(x)` and `*#@#^responseheader(x)` except `x` everywhere +// - an empty name, `hn#@#^responseheader()`, excepts every header on `hn` +// +// All filters for a header name are collected here, then compiled into one +// DNR rule per name: requestDomains for the hostnames, excludedRequestDomains +// for the exceptions. DNR excludes sub-domains and gives excluded domains +// precedence, which is what classic does. What DNR cannot express: +// - entity (`example.*`) and regex hostnames: a filter, or an exception, with +// no other hostname is rejected, otherwise they are dropped with a warning +// (for an exception, the header is then still removed there) +// - exceptions only apply within the same list, since each list is compiled +// into its own ruleset +// - a matching DNR allow rule of higher priority (i.e. a network exception +// filter) also cancels the header removal, classic ignores those + +// Same set as the runtime compiler: DNR matches main_frame only when listed. +const responseHeaderResourceTypes = [ + 'main_frame', + 'sub_frame', + 'stylesheet', + 'script', + 'image', + 'font', + 'object', + 'xmlhttprequest', + 'ping', + 'csp_report', + 'media', + 'websocket', + 'other', +]; + +const isEntity = hn => hn.endsWith('.*'); + +function responseHeaderDetails(context, name) { + context.responseHeaders ??= new Map(); + let details = context.responseHeaders.get(name); + if ( details === undefined ) { + details = { + generic: false, + hostnames: new Set(), + exceptions: new Set(), + warnings: [], + }; + context.responseHeaders.set(name, details); + } + return details; +} + +function addResponseHeaderFilter(context, parser) { + const name = parser.getResponseheaderName(); + const details = responseHeaderDetails(context, name); + const unsupported = []; + context.responseHeaderGlobalExceptions ??= new Set(); + if ( parser.isException() ) { + if ( parser.hasOptions() === false ) { + context.responseHeaderGlobalExceptions.add(name); + return; + } + let supported = 0; + for ( const { hn, not, bad } of parser.getExtFilterDomainIterator() ) { + if ( bad ) { continue; } + // https://github.com/gorhill/uBlock/issues/3375 + // There is no exception to an exception (classic's comment: + // its code turns such a hostname into a filter). + if ( not ) { continue; } + if ( hn === '*' ) { + context.responseHeaderGlobalExceptions.add(name); + supported += 1; + } else if ( isEntity(hn) || isRegexOrPath(hn) ) { + unsupported.push(hn); + } else { + details.exceptions.add(hn); + supported += 1; + } + } + if ( unsupported.length === 0 ) { return; } + if ( supported === 0 ) { + context.invalid.add(`Unsupported responseheader() hostname: ${parser.raw}`); + } else { + details.warnings.push(`Ignored unsupported responseheader() exception hostname: ${parser.raw}`); + } + return; + } + // Only exception filters are allowed to be global + if ( parser.hasOptions() === false ) { return; } + const hostnames = []; + let generic = false; + for ( const { hn, not, bad } of parser.getExtFilterDomainIterator() ) { + if ( bad ) { continue; } + if ( isEntity(hn) || isRegexOrPath(hn) ) { + unsupported.push(hn); + } else if ( not ) { + details.exceptions.add(hn); + } else if ( hn === '*' ) { + generic = true; + } else { + hostnames.push(hn); + } + } + if ( generic ) { + details.generic = true; + } else if ( hostnames.length !== 0 ) { + for ( const hn of hostnames ) { + details.hostnames.add(hn); + } + } else if ( unsupported.length !== 0 ) { + context.invalid.add(`Unsupported responseheader() hostname: ${parser.raw}`); + return; + } + if ( unsupported.length !== 0 ) { + details.warnings.push(`Ignored unsupported responseheader() hostname: ${parser.raw}`); + } +} + +function responseHeaderRulesFromContext(context) { + const rules = []; + if ( context.responseHeaders === undefined ) { return rules; } + const globalExceptions = context.responseHeaderGlobalExceptions; + if ( globalExceptions.has('') ) { return rules; } + const allHeaderExceptions = context.responseHeaders.get('')?.exceptions ?? []; + // The warnings of all-header exceptions are reported once, with the + // first rule. + let allHeaderWarnings = context.responseHeaders.get('')?.warnings ?? []; + for ( const [ name, details ] of context.responseHeaders ) { + if ( name === '' ) { continue; } + if ( globalExceptions.has(name) ) { continue; } + if ( details.generic === false && details.hostnames.size === 0 ) { + continue; + } + const rule = { + action: { + responseHeaders: [ + { + header: name, + operation: 'remove', + } + ], + type: 'modifyHeaders' + }, + condition: { + resourceTypes: responseHeaderResourceTypes.slice(), + }, + }; + if ( details.generic === false ) { + rule.condition.requestDomains = Array.from(details.hostnames).sort(); + } + const excluded = new Set([ ...details.exceptions, ...allHeaderExceptions ]); + if ( excluded.size !== 0 ) { + rule.condition.excludedRequestDomains = Array.from(excluded).sort(); + } + const warnings = [ ...details.warnings, ...allHeaderWarnings ]; + allHeaderWarnings = []; + if ( warnings.length !== 0 ) { + rule._warning = warnings; + } + rules.push(rule); + } + return rules; +} + +/******************************************************************************/ + function addExtendedToDNR(context, parser) { if ( parser.isExtendedFilter() === false ) { return false; } @@ -139,53 +310,7 @@ function addExtendedToDNR(context, parser) { // Response header filtering if ( parser.isResponseheaderFilter() ) { if ( parser.hasError() ) { return; } - if ( parser.hasOptions() === false ) { return; } - if ( parser.isException() ) { return; } - const node = parser.getBranchFromType(sfp.NODE_TYPE_EXT_PATTERN_RESPONSEHEADER); - if ( node === 0 ) { return; } - const header = parser.getNodeString(node); - if ( context.responseHeaderRules === undefined ) { - context.responseHeaderRules = []; - } - const rule = { - action: { - responseHeaders: [ - { - header, - operation: 'remove', - } - ], - type: 'modifyHeaders' - }, - condition: { - resourceTypes: [ - 'main_frame', - 'sub_frame' - ] - }, - }; - for ( const { hn, not, bad } of parser.getExtFilterDomainIterator() ) { - if ( bad ) { continue; } - if ( isRegexOrPath(hn) ) { continue; } - if ( not ) { - if ( rule.condition.excludedInitiatorDomains === undefined ) { - rule.condition.excludedInitiatorDomains = []; - } - rule.condition.excludedInitiatorDomains.push(hn); - continue; - } - if ( hn === '*' ) { - if ( rule.condition.initiatorDomains !== undefined ) { - rule.condition.initiatorDomains = undefined; - } - continue; - } - if ( rule.condition.initiatorDomains === undefined ) { - rule.condition.initiatorDomains = []; - } - rule.condition.initiatorDomains.push(hn); - } - context.responseHeaderRules.push(rule); + addResponseHeaderFilter(context, parser); return; } @@ -319,6 +444,20 @@ function addToDNR(context, list) { const sourceStart = writer.blocks.get('NETWORK_FILTERS:GOOD')?.length ?? 0; if ( compiler.compile(parser, writer) ) { + const lines = writer.blocks.get('NETWORK_FILTERS:GOOD') ?? []; + // The source filters of each compiled line, which the rules + // compiled from it carry as `_sourceFilters`: a filter compiles + // into one line per type, and lines can merge into one rule, so + // rule entries are not filters. + context.networkFilterSources ??= new Map(); + for ( let i = sourceStart; i < lines.length; i++ ) { + const sources = context.networkFilterSources.get(lines[i]); + if ( sources === undefined ) { + context.networkFilterSources.set(lines[i], [ line ]); + } else if ( sources.includes(line) === false ) { + sources.push(line); + } + } if ( typeof context.networkSourceIdentity === 'function' ) { context.networkSources ??= new Map(); const identities = context.networkSourceIdentity(parser); @@ -328,7 +467,6 @@ function addToDNR(context, list) { context.networkBadfilterKeys.add(identity.rawKey ?? identity.key); } } - const lines = writer.blocks.get('NETWORK_FILTERS:GOOD') ?? []; for ( let i = sourceStart; i < lines.length; i++ ) { const compiledLine = lines[i]; const fragment = JSON.parse(compiledLine)[2]; @@ -482,6 +620,98 @@ function finalizeRuleset(context, network) { /******************************************************************************/ +// Stable reason codes for the `_error` messages of the entries which could +// not be converted into DNR rules, so that build reports can count them per +// reason. Codes shared with the runtime compiler (ubo-parser.js) are spelled +// the same. `invalid-*` filters are rejected by classic uBO too, the other +// ones only because DNR cannot express them. + +const dnrErrorReasons = [ + [ /^Incompatible with DNR: uritransform=/, 'unsupported-urltransform-regex' ], + [ /^Incompatible with DNR \(need regexFilter\): uritransform=/, 'unsupported-urltransform-pattern' ], + // The DNR parser excludes the `redirect-rule` option (see `badTypes`) + [ /^Incompatible with DNR: .*[$,]redirect-rule(?:[=,]|\s*$)/is, 'unsupported-redirect-rule' ], + [ /^Incompatible with DNR: /, 'unsupported-option' ], + [ /^Can't salvage rule with unsupported domain= option: /, 'unsupported-domain' ], + [ /^regexFilter is not RE2-compatible: /, 'unsupported-regex' ], + [ /^Unsupported regex-based removeParam: /, 'unsupported-removeparam-regex' ], + [ /^Unsupported negated removeParam: /, 'unsupported-removeparam-negated' ], + [ /^strict1p not supported/, 'unsupported-strict-first-party' ], + [ /^strict3p not supported/, 'unsupported-strict-third-party' ], + [ /^"ipaddress=.*" not supported$/s, 'unsupported-ipaddress' ], + [ /^responseheader=".*" not supported$/s, 'unsupported-header-value' ], + [ /^requestheader=".*" not supported$/s, 'unsupported-requestheader' ], + [ /^Unpatchable redirect filter: /, 'unsupported-redirect-resource' ], + [ /^Unsupported responseheader\(\) hostname: /, 'unsupported-responseheader-hostname' ], + [ /^urlFilter already defined: /, 'unsupported-pattern' ], + [ /^Unsupported modifier /, 'unsupported-modifier' ], + [ /^Rejected filter: /, 'invalid-filter' ], + [ /^Invalid network filter in /, 'invalid-network-filter' ], +]; + +function dnrErrorReason(message) { + if ( typeof message !== 'string' ) { return 'other'; } + for ( const [ re, reason ] of dnrErrorReasons ) { + if ( re.test(message) ) { return reason; } + } + return 'other'; +} + +// Count the filters which could not be converted, from the entries which +// carry `_error`, and how many per reason. Filters are counted, not entries: +// a filter compiles into one entry per type, and an entry can merge several +// filters (`_sourceFilters`). An entry without sources (a filter rejected +// before compilation) is one filter. A filter counts once, under the reason +// of the first error of its first rejected entry, so that the reason counts +// add up to the total. +function dnrErrorSummary(rules) { + const reasons = new Map(); + const counted = new Set(); + let count = 0; + for ( const rule of rules ) { + if ( Boolean(rule._error) === false ) { continue; } + let filterCount = 1; + if ( rule._sourceFilters?.length ) { + filterCount = 0; + for ( const filter of rule._sourceFilters ) { + if ( counted.has(filter) ) { continue; } + counted.add(filter); + filterCount += 1; + } + if ( filterCount === 0 ) { continue; } + } + count += filterCount; + const reason = dnrErrorReason(rule._error[0]); + reasons.set(reason, (reasons.get(reason) ?? 0) + filterCount); + } + const sorted = Array.from(reasons).sort((a, b) => + b[1] - a[1] || (a[0] < b[0] ? -1 : 1) + ); + return { count, reasons: Object.fromEntries(sorted) }; +} + +// Count the network filters which were converted: they are the source of at +// least one entry without `_error`, and of no entry with `_error` (those are +// counted by dnrErrorSummary()). +function dnrConvertedFilterCount(rules) { + const converted = new Set(); + const rejected = new Set(); + for ( const rule of rules ) { + const filters = rule._error ? rejected : converted; + for ( const filter of rule._sourceFilters ?? [] ) { + filters.add(filter); + } + } + let count = 0; + for ( const filter of converted ) { + if ( rejected.has(filter) ) { continue; } + count += 1; + } + return count; +} + +/******************************************************************************/ + async function dnrRulesetFromRawLists(lists, options = {}) { const context = Object.assign({}, options); context.bad = options.networkBad; @@ -506,13 +736,17 @@ async function dnrRulesetFromRawLists(lists, options = {}) { specificCosmetic: context.specificCosmeticFilters, scriptlet: context.scriptletFilters, }; - if ( context.responseHeaderRules ) { - result.network.ruleset.push(...context.responseHeaderRules); - } + result.network.ruleset.push(...responseHeaderRulesFromContext(context)); finalizeRuleset(context, result.network); return result; } /******************************************************************************/ -export { dnrRulesetFromRawLists, mergeRules }; +export { + dnrConvertedFilterCount, + dnrErrorReason, + dnrErrorSummary, + dnrRulesetFromRawLists, + mergeRules, +}; diff --git a/src/js/static-net-filtering.js b/src/js/static-net-filtering.js index ec4b3f6eddd72..973ea0385410e 100644 --- a/src/js/static-net-filtering.js +++ b/src/js/static-net-filtering.js @@ -4673,6 +4673,13 @@ StaticNetFilteringEngine.prototype.dnrFromCompiled = function(op, context, ...ar const unserialize = CompiledListReader.unserialize; const buckets = new Map(); const attachSources = (rule, line, property, hostname) => { + // The source filters of the rule (see addToDNR()), so that filters, + // not rule entries, can be counted. + const filters = context.networkFilterSources?.get(line); + if ( filters !== undefined ) { + rule._sourceFilters ??= []; + rule._sourceFilters.push(...filters); + } if ( context.networkSources === undefined ) { return; } const keys = context.networkSources.get(line); rule._sourceKeys ??= []; @@ -4817,7 +4824,7 @@ StaticNetFilteringEngine.prototype.dnrFromCompiled = function(op, context, ...ar 'popup', ]); const ruleset = []; - const seen = new Set(); + const seen = new Map(); for ( const [ realmBits, realmDetails ] of realms ) { for ( const [ partyBits, partyName ] of partyness ) { for ( const typeName in typeNameToTypeValue ) { @@ -4841,9 +4848,21 @@ StaticNetFilteringEngine.prototype.dnrFromCompiled = function(op, context, ...ar rule.condition = rule.condition || {}; rule.condition.resourceTypes = [ typeName ]; } + // Source filters do not tell rules apart: those of + // a duplicate go to the rule which is kept. + const sources = rule._sourceFilters; + rule._sourceFilters = undefined; const hash = JSON.stringify(rule); - if ( seen.has(hash) ) { continue; } - seen.add(hash); + rule._sourceFilters = sources; + const kept = seen.get(hash); + if ( kept !== undefined ) { + if ( sources !== undefined ) { + kept._sourceFilters ??= []; + kept._sourceFilters.push(...sources); + } + continue; + } + seen.set(hash, rule); ruleset.push(rule); } } @@ -4851,6 +4870,10 @@ StaticNetFilteringEngine.prototype.dnrFromCompiled = function(op, context, ...ar } } seen.clear(); + for ( const rule of ruleset ) { + if ( rule._sourceFilters === undefined ) { continue; } + rule._sourceFilters = Array.from(new Set(rule._sourceFilters)); + } // Adjust `important` priority // Mind: @@ -4974,6 +4997,12 @@ StaticNetFilteringEngine.prototype.dnrFromCompiled = function(op, context, ...ar token = token.slice(0, match.index); } } + // A resource which cannot be redirected to (unknown token, or + // click2load.html) rejects the whole filter. Classic uBO still + // blocks such requests, since there `redirect=` also compiles into + // a block filter, which is not generated for DNR (see compile()): + // these filters are dropped, not blocked, and the runtime + // compiler (ubo-parser.js) rejects them the same way. const resource = context.extensionPaths.get(token); if ( rule.__modifierValue !== '' && resource === undefined ) { dnrAddRuleError(rule, `Unpatchable redirect filter: ${rule.__modifierValue}`); @@ -5002,8 +5031,14 @@ StaticNetFilteringEngine.prototype.dnrFromCompiled = function(op, context, ...ar } } }; - if ( /^~?\/.+\/$/.test(paramName) ) { + // DNR removes exact parameter names only: a regex value + // (`/re/`, `/re/i`, legacy `|prefix`) or a negated value + // (`~name`, keep only `name`) would silently match nothing. + const parsed = sfp.parseQueryPruneValue(paramName); + if ( parsed.re !== undefined || /^~?\/.+\/$/.test(paramName) ) { dnrAddRuleError(rule, `Unsupported regex-based removeParam: ${paramName}`); + } else if ( parsed.not ) { + dnrAddRuleError(rule, `Unsupported negated removeParam: ${paramName}`); } } else { rule.action.redirect = { diff --git a/tools/test-runtime-filter-options.mjs b/tools/test-runtime-filter-options.mjs new file mode 100644 index 0000000000000..194c4b53d86f1 --- /dev/null +++ b/tools/test-runtime-filter-options.mjs @@ -0,0 +1,615 @@ +/******************************************************************************* + + uBlock Plus+ - a community-powered MV3 content blocker + Copyright (C) 2026-present uBlock Plus+ contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + +*******************************************************************************/ + +// The runtime compiler (imported lists and "My filters") must never turn a +// filter option it cannot express into a broader rule. Each option either +// compiles like the stock build (src/js/static-dnr-filtering.js, as driven by +// platform/mv3/make-rulesets.js) or is rejected with a reason code that the +// compile report counts. + +import { dnrErrorReason, dnrRulesetFromRawLists } from '../src/js/static-dnr-filtering.js'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { isCompiledListData } from '../platform/mv3/extension/js/compiled-cache.js'; +import os from 'node:os'; +import path from 'node:path'; +import redirectResources from '../src/js/redirect-resources.js'; + +const projectRoot = fileURLToPath(new URL('../', import.meta.url)); +const temporaryRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'ublock-plus-runtime-filter-options-') +); + +try { + await fs.mkdir(path.join(temporaryRoot, 'js')); + await fs.mkdir(path.join(temporaryRoot, 'lib')); + await Promise.all([ + [ 'platform/mv3/extension/js/ubo-parser.js', 'js/ubo-parser.js' ], + [ 'platform/mv3/extension/js/compiled-popup-matcher.js', 'js/compiled-popup-matcher.js' ], + [ 'src/js/static-filtering-parser.js', 'js/static-filtering-parser.js' ], + [ 'src/js/arglist-parser.js', 'js/arglist-parser.js' ], + [ 'src/js/jsonpath.js', 'js/jsonpath.js' ], + [ 'src/js/redirect-resources.js', 'js/redirect-resources.js' ], + [ 'src/lib/punycode.js', 'js/punycode.js' ], + ].map(([ source, destination ]) => fs.copyFile( + path.join(projectRoot, source), path.join(temporaryRoot, destination) + ))); + await fs.cp(path.join(projectRoot, 'src/lib/csstree'), + path.join(temporaryRoot, 'lib/csstree'), { recursive: true }); + await fs.writeFile(path.join(temporaryRoot, 'package.json'), '{"type":"module"}\n'); + const sfp = await import(pathToFileURL( + path.join(temporaryRoot, 'js/static-filtering-parser.js') + )); + const { + NetworkFilterCompiler, + expandRemoveparamsRule, + parseNetworkFilter, + } = await import(pathToFileURL(path.join(temporaryRoot, 'js/ubo-parser.js'))); + + // Imported lists are untrusted; "My filters" are trusted. + const compile = (lines, trustedSource = false) => { + const parser = new sfp.AstFilterParser({ trustedSource, nativeCssHas: true }); + const compiler = new NetworkFilterCompiler({ listid: 'runtime-options-test' }); + const results = lines.map((line, index) => { + parser.parse(line); + assert.equal(parser.isNetworkFilter(), true, line); + return compiler.add(parser, index + 1); + }); + return { ...compiler.finish(), results }; + }; + // One filter, before cross-filter minimization, for exact comparisons. + const runtimeRules = (line, trustedSource = false) => { + const parser = new sfp.AstFilterParser({ trustedSource, nativeCssHas: true }); + parser.parse(line); + const rules = []; + const result = parseNetworkFilter(parser, { popupFilters: [] }, rules); + return { result, rules, parserError: parser.hasError() }; + }; + + // make-rulesets.js hands the stock converter every redirect-resources.js + // name and alias whose file it packages, except click2load.html: that + // page needs uBO's own scripts and the blocked frame URL as a parameter. + const stockUnmapped = new Set([ 'click2load.html' ]); + for ( const name of redirectResources.keys() ) { + if ( stockUnmapped.has(name) ) { continue; } + assert.ok(existsSync(path.join(projectRoot, 'src/web_accessible_resources', name)), + `${name} has a packaged file, so the stock build maps it`); + } + const extensionPaths = []; + for ( const [ name, details ] of redirectResources ) { + if ( stockUnmapped.has(name) ) { continue; } + const extensionPath = `/web_accessible_resources/${name}`; + extensionPaths.push([ name, extensionPath ]); + const aliases = details.alias === undefined ? [] + : Array.isArray(details.alias) ? details.alias : [ details.alias ]; + for ( const alias of aliases ) { extensionPaths.push([ alias, extensionPath ]); } + } + const stockRules = async line => { + const result = await dnrRulesetFromRawLists([ { name: 'stock', text: line } ], { + env: [ 'chromium', 'mv3', 'ubol', 'native_css_has' ], + extensionPaths, + secret: 'runtime-options-test', + }); + return result.network.ruleset; + }; + // Order-insensitive view of DNR rules; stock-only `_` metadata is dropped. + const normalize = rules => rules.map(rule => JSON.stringify({ + action: rule.action, + condition: rule.condition, + priority: rule.priority ?? 1, + }, (key, value) => { + if ( key.startsWith('_') ) { return; } + if ( Array.isArray(value) ) { + return value.every(a => typeof a === 'string') ? value.slice().sort() : value; + } + if ( value instanceof Object ) { + return Object.fromEntries(Object.keys(value).sort().map(k => [ k, value[k] ])); + } + return value; + })).sort(); + const assertRejected = (lines, reasonCode, trustedSource = false) => { + const compiled = compile(lines, trustedSource); + assert.deepEqual(compiled.dnrRules, [], lines.join('\n')); + assert.deepEqual(compiled.filterStats, { + total: lines.length, accepted: 0, rejected: lines.length, routed: 0, deferred: 0, + }, lines.join('\n')); + assert.deepEqual(compiled.rejections, lines.map((line, index) => ({ + status: 'rejected', reasonCode, lineNumber: index + 1, + })), lines.join('\n')); + }; + // Enough of the DNR matching algorithm for the conditions emitted here. + const isWithin = (hn, list) => list.some(d => hn === d || hn.endsWith(`.${d}`)); + const reFromUrlFilter = urlFilter => { + let prefix = ''; + let suffix = ''; + if ( urlFilter.startsWith('||') ) { + prefix = '^[a-z][a-z0-9+.-]*://(?:[^/?#]*\\.)?'; + urlFilter = urlFilter.slice(2); + } else if ( urlFilter.startsWith('|') ) { + prefix = '^'; + urlFilter = urlFilter.slice(1); + } + if ( urlFilter.endsWith('|') ) { + suffix = '$'; + urlFilter = urlFilter.slice(0, -1); + } + const body = Array.from(urlFilter, c => { + if ( c === '*' ) { return '.*'; } + if ( c === '^' ) { return '(?:[^a-z0-9_.%-]|$)'; } + return c.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + }).join(''); + return new RegExp(`${prefix}${body}${suffix}`, 'i'); + }; + const dnrMatches = (rule, { url, type, initiator }) => { + const { condition } = rule; + const { hostname } = new URL(url); + if ( condition.resourceTypes?.includes(type) === false ) { return false; } + if ( condition.excludedResourceTypes?.includes(type) ) { return false; } + if ( condition.requestDomains && isWithin(hostname, condition.requestDomains) === false ) { + return false; + } + if ( condition.excludedRequestDomains && isWithin(hostname, condition.excludedRequestDomains) ) { + return false; + } + if ( condition.initiatorDomains && + (initiator === undefined || isWithin(initiator, condition.initiatorDomains) === false) ) { + return false; + } + if ( condition.excludedInitiatorDomains && initiator !== undefined && + isWithin(initiator, condition.excludedInitiatorDomains) ) { + return false; + } + if ( condition.urlFilter !== undefined && + reFromUrlFilter(condition.urlFilter).test(url) === false ) { + return false; + } + if ( condition.regexFilter !== undefined && + new RegExp(condition.regexFilter).test(url) === false ) { + return false; + } + return condition.domainType === undefined; + }; + // DNR applies only the highest-priority matching rule; on a tie, allow + // beats block, which beats redirect. + const actionRanks = { allow: 3, block: 2, redirect: 1 }; + const winningRule = (rules, request) => rules + .filter(rule => rule.action.type !== 'modifyHeaders' && dnrMatches(rule, request)) + .sort((a, b) => (b.priority ?? 1) - (a.priority ?? 1) || + actionRanks[b.action.type] - actionRanks[a.action.type])[0]; + let checks = 0; + + // 1. $requestheader: DNR has no request-header condition. Dropping the + // option used to leave an unconditional block (or allow). + assertRejected([ + '||example.test^$requestheader=x-foo:bar', + '@@||example.test^$requestheader=x-foo:bar', + '*$script,requestheader=x-foo:bar,domain=site.test', + ], 'unsupported-requestheader'); + for ( const line of [ '||example.test^$requestheader=x-foo:bar' ] ) { + const stock = await stockRules(line); + assert.ok(stock.length !== 0 && stock.every(rule => + dnrErrorReason(rule._error?.[0]) === 'unsupported-requestheader'), + 'The stock build rejects $requestheader too, with the same reason code'); + } + checks += 2; + + // 2. $empty and $mp4 are redirect=empty and redirect=noopmp4-1s (media). + // They used to become a plain block of every request type. + const redirectFamily = [ + '||empty.test^$empty', + '||empty.test^$empty,important', + '||empty.test^$empty,script,3p,domain=site.test|~off.site.test', + '||empty.test/ads/$empty,xhr', + '||media.test^$mp4', + '||media.test^$mp4,important', + '||media.test^$mp4,domain=site.test', + '/media\\.test\\/ad[0-9]+\\.mp4/$mp4', + ]; + for ( const line of redirectFamily ) { + const { result, rules } = runtimeRules(line); + assert.equal(result.status, 'accepted', line); + assert.equal(rules.length, 1, line); + assert.deepEqual(normalize(rules), normalize(await stockRules(line)), line); + const isMp4 = line.includes('$mp4'); + assert.deepEqual(rules[0].action, { + type: 'redirect', + redirect: { + extensionPath: isMp4 + ? '/web_accessible_resources/noop-1s.mp4' + : '/web_accessible_resources/empty', + }, + }, line); + assert.equal(rules[0].priority, line.includes('important') ? 41 : 11, line); + if ( isMp4 ) { + assert.deepEqual(rules[0].condition.resourceTypes, [ 'media' ], line); + } + assert.ok(existsSync(path.join(projectRoot, 'src', rules[0].action.redirect.extensionPath))); + checks += 1; + } + assert.deepEqual(runtimeRules('||x.test^$empty').rules, + runtimeRules('||x.test^$redirect=empty').rules); + assert.deepEqual(runtimeRules('||x.test^$mp4').rules, + runtimeRules('||x.test^$media,redirect=noopmp4-1s').rules); + { + const compiled = compile([ '||empty.test^$empty', '||media.test^$mp4' ]); + assert.equal(compiled.filterStats.accepted, 2); + assert.equal(compiled.dnrRules.every(rule => rule.action.type === 'redirect'), true); + } + checks += 3; + + // A redirect exception cancels the redirection but keeps any block. DNR + // cannot express that, so it is rejected; $empty and $mp4 exceptions + // used to become a priority-30 allow of every request type. + const redirectExceptions = [ + '@@||empty.test^$empty', + '@@||media.test^$mp4', + '@@||x.test^$redirect=noop.js', + '@@||x.test^$redirect', + '@@||x.test^$rewrite=abp-resource:blank-js', + ]; + assertRejected(redirectExceptions, 'unsupported-redirect-exception'); + { + const compiled = compile([ '||ads.test^', '@@||ads.test^$empty', '@@||ads.test^$mp4' ]); + assert.deepEqual(compiled.dnrRules.map(rule => rule.action.type), [ 'block' ], + 'A redirect exception must not unblock the request'); + } + checks += 2; + + // 3. A bare $removeparam strips the whole query in classic uBO. An empty + // removeParams list is accepted by Chromium but strips nothing; DNR + // documents an empty URLTransform.query as clearing the query. + for ( const line of [ + '||track.test^$removeparam', + '||track.test^$queryprune', + '||track.test^$removeparam=|', + '||track.test^$removeparam,important', + ] ) { + const { result, rules } = runtimeRules(line); + assert.equal(result.status, 'accepted', line); + assert.deepEqual(rules[0].action, { + type: 'redirect', redirect: { transform: { query: '' } }, + }, line); + const stock = await stockRules(line); + assert.deepEqual(stock[0].action, rules[0].action, `${line} matches the stock action`); + // `important` deliberately differs from the stock build: see 6c. + if ( line.includes('important') === false ) { + assert.equal(rules[0].priority ?? 1, stock[0].priority ?? 1, line); + } + assert.equal(rules[0].condition.urlFilter, undefined, line); + checks += 1; + } + { + const compiled = compile([ + '||track.test^$removeparam', + '*$removeparam,domain=site.test', + '||keep.test^$removeparam=utm_source', + ]); + assert.equal(compiled.filterStats.rejected, 0); + const json = JSON.stringify(compiled.dnrRules); + assert.equal(json.includes('"removeParams":[]'), false, + 'No rule may carry an empty removeParams list'); + const clearing = compiled.dnrRules.filter(rule => + rule.action.redirect?.transform?.query === ''); + // Like removeParams rules, a navigation is keyed on its own hostname. + assert.deepEqual(clearing.find(rule => + rule.condition.resourceTypes?.length === 1 && + rule.condition.resourceTypes[0] === 'main_frame' && + rule.condition.initiatorDomains === undefined + )?.condition.requestDomains, [ 'site.test' ]); + assert.ok(clearing.some(rule => + rule.condition.initiatorDomains?.includes('site.test') && + rule.condition.resourceTypes.includes('main_frame') === false)); + assert.ok(compiled.dnrRules.some(rule => + rule.action.redirect?.transform?.queryTransform?.removeParams?.[0] === 'utm_source')); + const exception = runtimeRules('@@||track.test^$removeparam').rules; + assert.deepEqual(exception[0].action, { type: 'allow' }); + checks += 1; + } + + // 3b. A navigation is matched on its own hostname, which must satisfy + // both the request side (hostname pattern, `to=`) and `domain=`. + // Concatenated into one requestDomains list (an OR in DNR), the + // domain= sites lost their query on every page load. + const navigation = url => ({ + url, type: 'main_frame', initiator: new URL(url).hostname, + }); + for ( const [ lines, urls ] of [ + [ [ '||fastlane.rubiconproject.com^$removeparam,domain=aternos.org' ], + [ 'https://aternos.org/server/?x=1' ] ], + [ [ '||htlb.casalemedia.com^$removeparam=r,domain=aternos.org' ], + [ 'https://aternos.org/?r=1' ] ], + [ [ '*$removeparam,to=a.test,domain=site.test' ], [ 'https://site.test/?q=1' ] ], + [ [ '*$removeparam=x,to=a.test,domain=site.test|other.test' ], + [ 'https://site.test/?x=1', 'https://other.test/?x=1' ] ], + [ [ '*$removeparam=x,to=sub.site.test,domain=site.test|other.test' ], + [ 'https://site.test/?x=1', 'https://other.test/?x=1' ] ], + [ [ '||track.test^$doc,removeparam,domain=site.test' ], [ 'https://site.test/?q=1' ] ], + [ [ '||sub.site.test^$removeparam,domain=site.test' ], [ 'https://site.test/?q=1' ] ], + [ [ + '||fastlane.rubiconproject.com^$removeparam,domain=aternos.org', + '*$removeparam,to=a.test,domain=site.test', + ], [ 'https://aternos.org/server/?x=1', 'https://site.test/?q=1' ] ], + ] ) { + const compiled = compile(lines); + assert.equal(compiled.filterStats.accepted, lines.length, lines.join('\n')); + for ( const url of urls ) { + assert.equal(compiled.dnrRules.some(rule => dnrMatches(rule, navigation(url))), false, + `${lines.join('\n')} must not rewrite ${url}`); + } + checks += 1; + } + for ( const [ line, url ] of [ + [ '*$removeparam=x,to=sub.site.test,domain=site.test|other.test', 'https://sub.site.test/?x=1' ], + [ '*$removeparam=x,to=sub.site.test,domain=site.test|other.test', 'https://a.sub.site.test/?x=1' ], + [ '||sub.site.test^$removeparam,domain=site.test', 'https://sub.site.test/?q=1' ], + [ '||fastlane.rubiconproject.com^$removeparam,domain=fastlane.rubiconproject.com', + 'https://fastlane.rubiconproject.com/?q=1' ], + [ '*$removeparam,domain=site.test', 'https://site.test/?q=1' ], + [ '*$removeparam=x,domain=site.test|other.test', 'https://other.test/?x=1' ], + ] ) { + const { dnrRules } = compile([ line ]); + assert.ok(dnrRules.some(rule => dnrMatches(rule, navigation(url)) && + rule.action.type === 'redirect'), `${line} still rewrites ${url}`); + checks += 1; + } + { + // A filter whose navigations can never satisfy both is accepted and + // emits nothing: classic uBO never applies it either. + const { result, rules } = runtimeRules('||track.test^$doc,removeparam,domain=site.test'); + assert.equal(result.status, 'accepted'); + assert.deepEqual(rules, []); + // The stock build shares the expansion: it must not widen either. + const stock = { + action: { type: 'redirect', + redirect: { transform: { queryTransform: { removeParams: [ 'r' ] } } } }, + condition: { + requestDomains: [ 'htlb.casalemedia.com' ], initiatorDomains: [ 'aternos.org' ], + resourceTypes: [ 'image', 'main_frame', 'sub_frame', 'xmlhttprequest' ], + urlFilter: '^r=', + }, + }; + const out = [ stock ]; + assert.equal(expandRemoveparamsRule(stock, out), true); + assert.deepEqual(out, [ stock ]); + assert.deepEqual(stock.condition.resourceTypes, [ 'image', 'sub_frame', 'xmlhttprequest' ]); + const mainFrameOnly = structuredClone(stock); + mainFrameOnly.condition.resourceTypes = [ 'main_frame' ]; + const before = structuredClone(mainFrameOnly); + assert.equal(expandRemoveparamsRule(mainFrameOnly, [ mainFrameOnly ]), false); + assert.deepEqual(mainFrameOnly, before, 'An unmatchable rule is left as it was'); + checks += 2; + } + + // 4. Negated and regex values select keys by pattern; DNR removeParams + // takes exact keys only. + assertRejected([ + '*$removeparam=~utm_source', + '*$removeparam=~/^utm_/', + ], 'unsupported-removeparam-negated'); + assertRejected([ + '*$removeparam=/^utm_/', + '||track.test^$removeparam=/^(utm_source|fbclid)=/i', + '*$removeparam=|utm_', + ], 'unsupported-removeparam-regex'); + checks += 2; + + // 5. Redirect resources: every name and alias maps exactly as in the + // stock build, and whatever the stock build cannot map is rejected. + const tokens = []; + for ( const [ name, details ] of redirectResources ) { + tokens.push(name); + const aliases = details.alias === undefined ? [] + : Array.isArray(details.alias) ? details.alias : [ details.alias ]; + tokens.push(...aliases); + } + tokens.push('NOOP.JS', 'noop', 'missing-resource.js', 'noop.js:3', 'empty:9'); + for ( const token of tokens ) { + const line = `||redirect.test^$redirect=${token}`; + const { result, rules } = runtimeRules(line); + const stock = await stockRules(line); + if ( stock.some(rule => rule._error) ) { + assert.equal(result.status, 'rejected', token); + assert.equal(result.reasonCode, 'unsupported-redirect-resource', token); + assert.deepEqual(rules, [], token); + } else { + assert.equal(result.status, 'accepted', token); + assert.deepEqual(normalize(rules), normalize(stock), token); + assert.ok(existsSync(path.join(projectRoot, 'src', + rules[0].action.redirect.extensionPath)), token); + } + checks += 1; + } + assertRejected([ '||frame.test^$frame,redirect=click2load.html' ], + 'unsupported-redirect-resource'); + + // 6a. An exception $uritransform only cancels a transform. It used to + // compile into the transform itself, and exceptions bypass the + // parser's trusted-source check, so an imported list could redirect. + assertRejected([ + '@@/^https:\\/\\/a\\.test\\/(.*)/$uritransform=//https:\\/\\/evil.test\\/$1/', + '@@||a.test^$uritransform', + ], 'unsupported-urltransform-exception'); + { + const { result, rules } = runtimeRules( + '/^https:\\/\\/a\\.test\\/(.*)/$uritransform=//https:\\/\\/b.test\\/$1/', true); + assert.equal(result.status, 'accepted', 'Trusted transforms still compile'); + assert.deepEqual(rules[0].action, + { type: 'redirect', redirect: { regexSubstitution: 'https://b.test/\\1' } }); + } + checks += 2; + + // 6b. `|` separates policies in $permissions; left in place it made the + // appended Permissions-Policy header unparseable, i.e. a no-op. + { + const line = '||perm.test^$permissions=camera=()|microphone=()'; + const { rules } = runtimeRules(line); + assert.equal(rules[0].action.responseHeaders[0].value, 'camera=(), microphone=()'); + const stock = await stockRules(line); + assert.equal(stock[0].action.responseHeaders[0].value, 'camera=(), microphone=()'); + checks += 1; + } + + // 6c. `important` on a modifier filter was silently dropped: the rule kept + // priority 1, tied with the exceptions of its kind. Header rules are + // matched apart from blocks and, like the stock build, get 31. + for ( const line of [ + "||csp.test^$csp=script-src 'none',important", + '||perm.test^$permissions=camera=(),important', + ] ) { + const { rules } = runtimeRules(line); + const stock = await stockRules(line); + assert.equal(rules[0].priority, 31, line); + assert.ok(stock.every(rule => rule.priority === 31), line); + checks += 1; + } + assert.equal(runtimeRules('||track.test^$removeparam=utm_source').rules[0].priority, undefined); + // A transform stays below every block (10): DNR applies only the top + // matching rule, and one with nothing to change does nothing, so at the + // stock build's 31 it cancelled the block. Classic uBO blocks first. + for ( const [ line, trustedSource ] of [ + [ '||track.test^$removeparam=utm_source,important', false ], + [ '||track.test^$removeparam,important', false ], + [ '/^https:\\/\\/a\\.test\\/(.*)/$uritransform=//https:\\/\\/b.test\\/$1/,important', true ], + ] ) { + const { result, rules } = runtimeRules(line, trustedSource); + assert.equal(result.status, 'accepted', line); + assert.equal(rules[0].priority, 2, line); + checks += 1; + } + { + const { dnrRules } = compile([ + '||ads.test^$script', + '||ads.test/ad.js$script,important,removeparam=utm', + '||ads.test^$script,removeparam,important', + '||tracker.test^$removeparam=utm,important', + '@@||tracker.test^$removeparam=utm', + ]); + for ( const url of [ + 'https://ads.test/ad.js', 'https://ads.test/ad.js?utm=1', 'https://ads.test/ad.js?q=1', + ] ) { + const request = { url, type: 'script', initiator: 'site.test' }; + assert.equal(winningRule(dnrRules, request)?.action.type, 'block', url); + } + // Still above the `@@$removeparam` exceptions, as in classic uBO. + const request = { url: 'https://tracker.test/?utm=1', type: 'xmlhttprequest', initiator: 'site.test' }; + assert.deepEqual(winningRule(dnrRules, request)?.action.redirect, + { transform: { queryTransform: { removeParams: [ 'utm' ] } } }); + checks += 2; + } + + // 6d. `to=` narrows a hostname pattern (both must match). It used to + // replace the pattern, blocking every request to the `to=` domains. + { + const { rules } = runtimeRules('||cdn.test^$to=other.test'); + assert.deepEqual(rules[0].condition, { + urlFilter: '||cdn.test^', requestDomains: [ 'other.test' ], + }); + const nested = compile([ + '||sub.cdn.test^$script,to=cdn.test', + '||sub.cdn.test^$to=cdn.test,removeparam=utm_source', + '||cdn.test^$to=~img.cdn.test', + ]); + assert.equal(nested.filterStats.rejected, 0); + const script = nested.dnrRules.find(rule => rule.action.type === 'block' && + rule.condition.resourceTypes?.includes('script')); + assert.equal(script.condition.urlFilter, '||sub.cdn.test^'); + assert.deepEqual(script.condition.requestDomains, [ 'cdn.test' ]); + const strip = nested.dnrRules.find(rule => rule.action.type === 'redirect'); + assert.equal(strip.condition.urlFilter, '||sub.cdn.test^*^utm_source='); + const excluded = nested.dnrRules.find(rule => + rule.condition.excludedRequestDomains !== undefined); + assert.deepEqual(excluded.condition.requestDomains, [ 'cdn.test' ]); + assert.equal(excluded.condition.urlFilter, undefined); + checks += 2; + } + + // 6e. Guard: no option name the parser knows may compile to exactly the + // rule of the same filter without that option, unless the option is + // meaningless for matching. Covers every NODE_TYPE_NET_OPTION_NAME_*. + const neutralOptions = new Set([ '_', 'reason' ]); + const sampleValues = new Map([ + [ 'csp', "script-src 'none'" ], [ 'denyallow', 'x.test' ], + [ 'from', 'site.test' ], [ 'domain', 'site.test' ], + [ 'ipaddress', '1.2.3.4' ], [ 'method', 'post' ], + [ 'permissions', 'camera=()' ], [ 'reason', 'blocked' ], + [ 'redirect', 'noop.js' ], [ 'rewrite', 'noop.js' ], + [ 'redirect-rule', 'noop.js' ], [ 'replace', '/a/b/' ], + [ 'requestheader', 'x-a:b' ], [ 'responseheader', 'x-a:b' ], + [ 'header', 'x-a:b' ], [ 'to', 'other.test' ], [ 'top', 'site.test' ], + [ 'urlskip', '?url' ], [ 'uritransform', '//x/' ], + ]); + const optionNodeTypes = new Set(Object.entries(sfp) + .filter(([ name ]) => name.startsWith('NODE_TYPE_NET_OPTION_NAME_')) + .map(([ , value ]) => value)); + optionNodeTypes.delete(sfp.NODE_TYPE_NET_OPTION_NAME_NOT); + optionNodeTypes.delete(sfp.NODE_TYPE_NET_OPTION_NAME_UNKNOWN); + const scannedNodeTypes = new Set(); + for ( const [ name, nodeType ] of sfp.nodeTypeFromOptionName ) { + if ( name === '' ) { continue; } + scannedNodeTypes.add(nodeType); + const suffix = name === 'denyallow' ? ',domain=site.test' : ''; + const option = sampleValues.has(name) ? `${name}=${sampleValues.get(name)}` : name; + for ( const prefix of [ '', '@@' ] ) { + for ( const trustedSource of [ false, true ] ) { + const line = `${prefix}||opt.test^$${option}${suffix}`; + const actual = runtimeRules(line, trustedSource); + if ( actual.parserError || actual.result.status !== 'accepted' ) { + if ( actual.parserError === false ) { + assert.match(actual.result.reasonCode, /^[a-z0-9]+(?:-[a-z0-9]+)*$/, line); + } + continue; + } + if ( neutralOptions.has(name) ) { continue; } + const plain = runtimeRules(`${prefix}||opt.test^${suffix ? `$${suffix.slice(1)}` : ''}`, + trustedSource); + assert.notDeepEqual(actual.rules, plain.rules, + `$${name} silently compiles to the rule without it: ${line}`); + } + } + checks += 1; + } + assert.deepEqual(Array.from(optionNodeTypes).filter(type => scannedNodeTypes.has(type) === false), + [], 'Every option node type has a name the guard exercises'); + + // 7. The new reason codes travel through the existing compile report: + // the cached list data which carries filterStats and rejections must + // still pass the validator applied before activation. + { + const compiled = compile([ + '||example.test^$requestheader=x-foo:bar', + '@@||empty.test^$empty', + '@@/^https:\\/\\/a\\.test\\/(.*)/$uritransform=//https:\\/\\/evil.test\\/$1/', + '||empty.test^$empty', + '||track.test^$removeparam', + ]); + assert.deepEqual(compiled.rejections.map(a => [ a.lineNumber, a.reasonCode ]), [ + [ 1, 'unsupported-requestheader' ], + [ 2, 'unsupported-redirect-exception' ], + [ 3, 'unsupported-urltransform-exception' ], + ]); + assert.equal(compiled.filterStats.rejected, 3); + assert.equal(compiled.filterStats.accepted, 2); + assert.equal(isCompiledListData({ + ...compiled, + specificCosmeticDetails: new Map(), + scriptletDetails: new Map(), + ruleStats: { total: compiled.dnrRules.length, plain: compiled.dnrRules.length, regex: 0 }, + }), true); + checks += 1; + } + + console.log(`Runtime filter option tests passed (${checks} checks)`); +} finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/tools/test-stock-compiler-honesty.mjs b/tools/test-stock-compiler-honesty.mjs new file mode 100644 index 0000000000000..29080d313edb6 --- /dev/null +++ b/tools/test-stock-compiler-honesty.mjs @@ -0,0 +1,643 @@ +/******************************************************************************* + + uBlock Plus+ - a community-powered MV3 content blocker + Copyright (C) 2026-present uBlock Plus+ contributors + License: GPLv3 or later. + +*******************************************************************************/ + +// Stock (build-time) compiler honesty: +// - every filter which cannot become a DNR rule is counted, per reason, in the +// build log and in ruleset-details.json, and validate-mv3.mjs checks that +// the reported counts match the packaged rulesets; filters are counted, not +// the rule entries they compile into, and the dashboard shows the number of +// filters converted +// - `removeparam=~name` is reported as unsupported, not converted into a +// removeParams entry which never matches +// - `##^responseheader()` is keyed on the response's own hostname, with the +// exception semantics of classic uBO (httpheader-filtering.js) +// - every redirect resource the runtime compiler accepts is packaged and +// web-accessible, or listed as unavailable; a missing one fails the build + +import { + dnrConvertedFilterCount, + dnrErrorReason, + dnrErrorSummary, + dnrRulesetFromRawLists, +} from '../src/js/static-dnr-filtering.js'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { promisify } from 'node:util'; +import redirectResources from '../src/js/redirect-resources.js'; + +const projectRoot = fileURLToPath(new URL('../', import.meta.url)); +const env = [ 'chromium', 'native_css_has', 'mv3', 'ublock', 'ubol' ]; +const extensionPaths = [ [ 'noop.js', '/web_accessible_resources/noop.js' ] ]; +// Fixture lists are trusted, as the stock lists which use these options are. +const secret = 'honesty-test'; + +const compile = async lines => { + const result = await dnrRulesetFromRawLists( + [ { name: 'honesty-test', text: [ `!#trusted on ${secret}`, ...lines ].join('\n') } ], + { env, extensionPaths, secret } + ); + const rules = result.network.ruleset; + return { + rules: rules.filter(rule => rule._error === undefined), + errors: rules.filter(rule => rule._error !== undefined), + }; +}; + +const headerRules = rules => rules.filter(rule => + rule.action?.type === 'modifyHeaders' && + rule.action.responseHeaders?.some(header => header.operation === 'remove') +); + +/******************************************************************************/ + +// Every error message which the stock compiler can produce maps to a stable +// reason code. +{ + const expected = new Map([ + [ '||a.fixture.test^$script,redirect-rule=noop.js', 'unsupported-redirect-rule' ], + [ '||b.fixture.test^$domain=example.*', 'unsupported-domain' ], + [ '/ab(?=c)d/$script', 'unsupported-regex' ], + [ '||c.fixture.test^$ipaddress=192.0.2.1', 'unsupported-ipaddress' ], + [ '||d.fixture.test^$removeparam=/^utm_/', 'unsupported-removeparam-regex' ], + [ '||e.fixture.test^$removeparam=/^utm_/i', 'unsupported-removeparam-regex' ], + // Legacy `|prefix` value: a regex for classic uBO + [ '||e2.fixture.test^$removeparam=|utm_', 'unsupported-removeparam-regex' ], + [ '||e3.fixture.test^$removeparam=~/^utm_/', 'unsupported-removeparam-regex' ], + [ '||f.fixture.test^$removeparam=~keep', 'unsupported-removeparam-negated' ], + [ '||g.fixture.test^$strict1p', 'unsupported-strict-first-party' ], + [ '||h.fixture.test^$strict3p', 'unsupported-strict-third-party' ], + [ '||i.fixture.test^$frame,redirect=click2load.html', 'unsupported-redirect-resource' ], + [ '||j.fixture.test^$requestheader=x-foo:bar', 'unsupported-requestheader' ], + [ '||k.fixture.test^$header=etag:/^W\\/"[0-9a-f]{64}"$/', 'unsupported-header-value' ], + [ '||l.fixture.test^$uritransform=/a/b/', 'unsupported-urltransform-regex' ], + [ 'l.*##^responseheader(refresh)', 'unsupported-responseheader-hostname' ], + [ 'l.*#@#^responseheader(refresh)', 'unsupported-responseheader-hostname' ], + [ 'fixture.test##:-abp-properties(width: 1px)', 'invalid-filter' ], + [ '@@||m.fixture.test^$genericblock', 'invalid-network-filter' ], + ]); + const { errors } = await compile(Array.from(expected.keys())); + assert.equal(errors.length, expected.size); + for ( const rule of errors ) { + assert.notEqual(dnrErrorReason(rule._error[0]), 'other', rule._error[0]); + } + const counts = new Map(); + for ( const reason of expected.values() ) { + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + const summary = dnrErrorSummary([ ...errors, { action: { type: 'block' } } ]); + assert.equal(summary.count, expected.size); + assert.deepEqual(summary.reasons, Object.fromEntries(Array.from(counts) + .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)))); + assert.equal(dnrErrorReason('Something new'), 'other'); + assert.equal(dnrErrorReason(undefined), 'other'); +} + +// Filters are counted, not the entries they compile into: one entry per +// type, entries merging several filters, entries with several errors. +{ + const { rules, errors } = await compile([ + // 3 entries, 1 filter + '||types.fixture.test^$script,image,xhr,strict1p', + // 1 entry, 2 filters + '*$script,domain=merged1.*', + '*$script,domain=merged2.*', + // 2 errors, 1 entry: counted under its first error + '||multi.fixture.test^$strict1p,domain=example.*', + // 2 compiled lines, 1 entry once duplicates are removed: 2 filters + '||dup.fixture.test^$script,strict3p,domain=x.test|y.test', + '||dup.fixture.test^$script,strict3p,domain=y.test|x.test', + '||ok.fixture.test^$script,image', + '||ok.fixture.test^$script', + ]); + const types = errors.filter(rule => + rule._sourceFilters.includes('||types.fixture.test^$script,image,xhr,strict1p')); + assert.equal(types.length, 3); + const merged = errors.filter(rule => + rule._sourceFilters.includes('*$script,domain=merged1.*')); + assert.equal(merged.length, 1); + assert.deepEqual(merged[0]._sourceFilters.slice().sort(), + [ '*$script,domain=merged1.*', '*$script,domain=merged2.*' ]); + const multi = errors.find(rule => + rule._sourceFilters.includes('||multi.fixture.test^$strict1p,domain=example.*')); + assert.equal(multi._error.length, 2); + assert.equal(dnrErrorReason(multi._error[0]), 'unsupported-strict-first-party'); + assert.equal(dnrErrorReason(multi._error[1]), 'unsupported-domain'); + const dup = errors.filter(rule => + rule.condition?.requestDomains?.includes('dup.fixture.test')); + assert.equal(dup.length, 1); + assert.equal(dup[0]._sourceFilters.length, 2); + assert.deepEqual(dnrErrorSummary(errors), { + count: 6, + reasons: { + 'unsupported-strict-first-party': 2, + 'unsupported-domain': 2, + 'unsupported-strict-third-party': 2, + }, + }); + // `||ok…^$script` is a duplicate of a compiled line of the other `ok` + // filter: both are converted, and neither is counted twice. + assert.equal(dnrConvertedFilterCount([ ...rules, ...errors ]), 2); + // A filter with any rejected entry is not converted. + assert.equal(dnrConvertedFilterCount([ + { action: { type: 'block' }, _sourceFilters: [ 'a', 'b' ] }, + { _error: [ 'x' ], _sourceFilters: [ 'b' ] }, + { _error: [ 'y' ] }, + ]), 1); +} + +// A negated removeparam keeps only the named parameter: DNR cannot express +// it, while a plain name and the remove-all form still convert. +{ + const { rules, errors } = await compile([ + '||negated.fixture.test^$removeparam=~keep', + '@@||negated.fixture.test^$removeparam=~keep', + '||plain.fixture.test^$removeparam=utm_source', + '||all.fixture.test^$removeparam', + ]); + assert.equal(errors.length, 2); + for ( const rule of errors ) { + assert.equal(dnrErrorReason(rule._error[0]), 'unsupported-removeparam-negated'); + } + assert.equal(rules.some(rule => + JSON.stringify(rule).includes('~keep') + ), false, 'No rule mentions the negated parameter'); + const plain = rules.find(rule => + rule.condition.requestDomains?.includes('plain.fixture.test')); + assert.deepEqual(plain.action.redirect.transform.queryTransform.removeParams, + [ 'utm_source' ]); + const all = rules.find(rule => + rule.condition.requestDomains?.includes('all.fixture.test')); + assert.deepEqual(all.action.redirect.transform, { query: '' }); +} + +// Response header filters are keyed on the response's hostname, for every +// type of request, and classic exception semantics hold within a list. +{ + const { rules, errors } = await compile([ + 'a.fixture.test,~sub.a.fixture.test##^responseheader(refresh)', + 'b.fixture.test##^responseheader(refresh)', + 'c.fixture.test,d.fixture.test##^responseheader(set-cookie)', + 'c.fixture.test#@#^responseheader(set-cookie)', + 'e.fixture.test##^responseheader(report-to)', + '#@#^responseheader(report-to)', + 'f.fixture.test#@#^responseheader()', + '~only-negated.fixture.test##^responseheader(location)', + 'entity.*,g.fixture.test##^responseheader(location)', + ]); + assert.deepEqual(errors, []); + const byHeader = new Map(headerRules(rules).map(rule => + [ rule.action.responseHeaders[0].header, rule ] + )); + assert.deepEqual(Array.from(byHeader.keys()).sort(), + [ 'location', 'refresh', 'set-cookie' ], + 'A global exception drops every report-to filter'); + for ( const rule of byHeader.values() ) { + assert.equal(rule.condition.initiatorDomains, undefined); + assert.equal(rule.condition.excludedInitiatorDomains, undefined); + for ( const type of [ 'main_frame', 'sub_frame', 'image', 'script', 'xmlhttprequest' ] ) { + assert.ok(rule.condition.resourceTypes.includes(type), type); + } + assert.deepEqual(rule.action.responseHeaders.length, 1); + assert.equal(rule.action.responseHeaders[0].operation, 'remove'); + } + const refresh = byHeader.get('refresh').condition; + assert.deepEqual(refresh.requestDomains, [ 'a.fixture.test', 'b.fixture.test' ]); + assert.deepEqual(refresh.excludedRequestDomains, + [ 'f.fixture.test', 'sub.a.fixture.test' ], + 'A negated hostname excepts it for every filter of that header'); + const cookie = byHeader.get('set-cookie').condition; + assert.deepEqual(cookie.requestDomains, [ 'c.fixture.test', 'd.fixture.test' ]); + assert.deepEqual(cookie.excludedRequestDomains, [ 'c.fixture.test', 'f.fixture.test' ]); + const location = byHeader.get('location'); + assert.deepEqual(location.condition.requestDomains, [ 'g.fixture.test' ], + 'Entities are dropped when a plain hostname remains'); + assert.deepEqual(location.condition.excludedRequestDomains, + [ 'f.fixture.test', 'only-negated.fixture.test' ]); + assert.ok(location._warning.some(warning => warning.includes('entity.*'))); + + // Generic filter; negated-only filter; entity-only filter + const generic = await compile([ '*,~h.fixture.test##^responseheader(location)' ]); + const genericRule = headerRules(generic.rules)[0]; + assert.equal(genericRule.condition.requestDomains, undefined); + assert.deepEqual(genericRule.condition.excludedRequestDomains, [ 'h.fixture.test' ]); + assert.ok(genericRule.condition.resourceTypes.includes('main_frame')); + const negated = await compile([ '~i.fixture.test##^responseheader(location)' ]); + assert.deepEqual(headerRules(negated.rules), [], + 'A filter with only negated hostnames is an exception, as in classic'); + assert.deepEqual(negated.errors, []); + const entity = await compile([ 'entity.*##^responseheader(location)' ]); + assert.deepEqual(headerRules(entity.rules), [], + 'An entity-only filter must not become a global rule'); + assert.equal(entity.errors.length, 1); + assert.equal(dnrErrorReason(entity.errors[0]._error[0]), + 'unsupported-responseheader-hostname'); + const everything = await compile([ + 'j.fixture.test##^responseheader(location)', + '#@#^responseheader()', + ]); + assert.deepEqual(headerRules(everything.rules), []); +} + +// Response header exceptions which DNR cannot fully express. +{ + const refreshRules = rules => headerRules(rules).filter(rule => + rule.action.responseHeaders[0].header === 'refresh'); + // `*` excepts the header everywhere, as no hostname does + const star = await compile([ + 'k.fixture.test##^responseheader(report-to)', + '*#@#^responseheader(report-to)', + ]); + assert.deepEqual(headerRules(star.rules), []); + assert.deepEqual(star.errors, []); + // No exception to an exception: negated hostnames are ignored + const negated = await compile([ + 'k.fixture.test##^responseheader(refresh)', + '~k.fixture.test#@#^responseheader(refresh)', + '~sub.k.fixture.test#@#^responseheader(refresh)', + ]); + const negatedRules = refreshRules(negated.rules); + assert.equal(negatedRules.length, 1); + assert.deepEqual(negatedRules[0].condition.requestDomains, [ 'k.fixture.test' ]); + assert.equal(negatedRules[0].condition.excludedRequestDomains, undefined); + assert.deepEqual(negated.errors, []); + // Entity and regex hostnames are dropped, with a warning when another + // hostname remains... + const partial = await compile([ + 'k.fixture.test##^responseheader(refresh)', + 'x.*,y.fixture.test,/re\\.fixture/#@#^responseheader(refresh)', + ]); + const partialRule = refreshRules(partial.rules)[0]; + assert.deepEqual(partialRule.condition.excludedRequestDomains, [ 'y.fixture.test' ]); + assert.deepEqual(partialRule._warning, [ + 'Ignored unsupported responseheader() exception hostname: ' + + 'x.*,y.fixture.test,/re\\.fixture/#@#^responseheader(refresh)', + ]); + assert.deepEqual(partial.errors, []); + // ...otherwise the exception is counted as unsupported, like a filter + // (the header is then still removed where classic would keep it) + const unsupported = await compile([ + 'k.fixture.test##^responseheader(refresh)', + 'k.*#@#^responseheader(refresh)', + '/k\\.fixture/#@#^responseheader(refresh)', + 'n.*#@#^responseheader(location)', + ]); + const unsupportedRule = refreshRules(unsupported.rules)[0]; + assert.equal(unsupportedRule.condition.excludedRequestDomains, undefined); + assert.equal(unsupportedRule._warning, undefined); + assert.deepEqual(dnrErrorSummary(unsupported.errors), { + count: 3, + reasons: { 'unsupported-responseheader-hostname': 3 }, + }); + // The warning of an all-header exception is reported once + const allHeaders = await compile([ + 'k.fixture.test##^responseheader(refresh)', + 'k.fixture.test##^responseheader(location)', + 'x.*,z.fixture.test#@#^responseheader()', + ]); + const allHeaderRules = headerRules(allHeaders.rules); + assert.equal(allHeaderRules.length, 2); + for ( const rule of allHeaderRules ) { + assert.deepEqual(rule.condition.excludedRequestDomains, [ 'z.fixture.test' ]); + } + assert.equal(allHeaderRules.flatMap(rule => rule._warning ?? []).length, 1); +} + +// The dashboard's "N rules, converted from M network filters": M is what a +// stock ruleset converted, not what it accepted (which includes the filters +// counted as rejected, once per type); imported lists count converted filters +// as accepted. +{ + const { + FakeDocument, createExtension, stageModules, + } = await import('./dashboard-test-harness.mjs'); + const staged = await stageModules({ + modules: [ 'filter-lists.js' ], + stubs: { + 'dashboard.js': 'export const hashFromIterable = ( ) => \'\';\n' + + 'export const nodeFromTemplate = ( ) => null;\n', + }, + document: new FakeDocument(), + extension: createExtension(), + }); + try { + const { rulesetStatsFromDetails } = await staged.load('filter-lists.js'); + assert.deepEqual(rulesetStatsFromDetails({ + filters: { total: 12, accepted: 11, rejected: 1, converted: 4 }, + rules: { total: 6, plain: 5, regex: 1, rejected: 3 }, + }), { ruleCount: 6, filterCount: 4, unsupportedCount: 3 }); + assert.deepEqual(rulesetStatsFromDetails({ + filters: { total: 3, accepted: 2, rejected: 1 }, + rules: { total: 2, plain: 2, regex: 0 }, + }), { ruleCount: 2, filterCount: 2, unsupportedCount: 0 }); + } finally { + await staged.cleanup(); + } +} + +/******************************************************************************/ + +// make-rulesets.js: stage it the way tools/make-mv3.ps1 does, compile two +// fixture lists, and check the reports, the packaged redirect resources and +// validate-mv3.mjs. + +const run = promisify(execFile); +const temporaryParent = await fs.realpath(os.tmpdir()); +const temporaryRoot = await fs.mkdtemp( + path.join(temporaryParent, 'ublock-plus-stock-honesty-') +); +const fromRoot = relative => path.join(projectRoot, relative); + +const stageRulesetBuild = async buildDir => { + const copy = (from, to) => fs.cp(fromRoot(from), path.join(buildDir, to), + { recursive: true }); + await fs.mkdir(path.join(buildDir, 'js'), { recursive: true }); + for ( const file of [ + 'arglist-parser.js', 'base64-custom.js', 'biditrie.js', + 'dynamic-net-filtering.js', 'filtering-context.js', 'hnswitches.js', + 'hntrie.js', 'jsonpath.js', 'redirect-resources.js', + 'regex-analyzer.js', 's14e-serializer.js', 'static-dnr-filtering.js', + 'static-filtering-parser.js', 'static-net-filtering.js', + 'static-filtering-io.js', 'tasks.js', 'text-utils.js', 'urlskip.js', + 'uri-utils.js', 'url-net-filtering.js', + ] ) { + await copy(`src/js/${file}`, `js/${file}`); + } + await copy('src/lib/csstree', 'lib/csstree'); + await copy('src/lib/punycode.js', 'lib/punycode.js'); + await copy('src/lib/regexanalyzer', 'lib/regexanalyzer'); + await copy('src/lib/publicsuffixlist', 'lib/publicsuffixlist'); + for ( const file of await fs.readdir(fromRoot('platform/mv3')) ) { + if ( /\.(?:json|m?js)$/.test(file) === false ) { continue; } + await copy(`platform/mv3/${file}`, file); + } + for ( const file of [ + 'ubo-parser.js', 'compiled-popup-matcher.js', 'utils.js', + 'imported-fetch-policy.js', + ] ) { + await copy(`platform/mv3/extension/js/${file}`, `js/${file}`); + } + await copy('src/lib/punycode.js', 'js/punycode.js'); + await copy('src/lib/regexanalyzer', 'js/regexanalyzer'); + await copy('src/js/resources', 'js/resources'); + await copy('platform/mv3/scriptlets', 'scriptlets'); + await copy('platform/mv3/extension/js/offscreen', 'js/offscreen'); + await copy('src/js/regex-analyzer.js', 'js/offscreen/regex-analyzer.js'); + await copy('src/web_accessible_resources', 'web_accessible_resources'); + await copy('platform/mv3/chromium', 'chromium'); +}; + +const fixtureRulesets = [ { + id: 'honesty-a', + name: 'Honesty A', + group: 'default', + enabled: true, + urls: [], + filters: [ + '||ads.fixture.test^', + '||noop.fixture.test^$script,redirect=noop.js', + '||vast.fixture.test^$xhr,redirect=noopvast-4.0', + '||rr.fixture.test^$script,redirect-rule=noop.js', + '||rr2.fixture.test^$image,redirect-rule=1x1.gif', + '||neg.fixture.test^$removeparam=~keep', + '||plain.fixture.test^$removeparam=utm_source', + '||c2l.fixture.test^$frame,redirect=click2load.html', + '||dom.fixture.test^$domain=example.*', + // 2 entries, 1 filter + '||types.fixture.test^$script,image,strict3p', + // 1 entry, 2 errors + '||multi.fixture.test^$strict1p,domain=example.*', + // Matches no navigation: the navigated hostname would have to be both + '||rp.fixture.test^$removeparam=r,doc,domain=a.fixture.test', + 'fixture.test##^responseheader(location)', + 'fixture.test##:-abp-properties(width: 1px)', + ], +}, { + id: 'honesty-b', + name: 'Honesty B', + group: 'default', + enabled: false, + urls: [], + filters: [ + '||clean.fixture.test^', + ], +} ]; +const expectedRejected = { + 'honesty-a': { + rejected: 8, + rejectedReasons: { + 'unsupported-redirect-rule': 2, + 'invalid-filter': 1, + 'unsupported-domain': 1, + 'unsupported-redirect-resource': 1, + 'unsupported-removeparam-negated': 1, + 'unsupported-strict-first-party': 1, + 'unsupported-strict-third-party': 1, + }, + // ads, noop, vast, plain and rp + converted: 5, + }, + 'honesty-b': { rejected: 0, rejectedReasons: undefined, converted: 1 }, +}; + +try { + const buildDir = path.join(temporaryRoot, 'ruleset-build'); + const outputDir = path.join(temporaryRoot, 'build', 'uBlockPlus.chromium'); + await stageRulesetBuild(buildDir); + await fs.writeFile(path.join(buildDir, 'rulesets.json'), + JSON.stringify(fixtureRulesets)); + await fs.mkdir(outputDir, { recursive: true }); + await fs.copyFile(fromRoot('platform/mv3/chromium/manifest.json'), + path.join(outputDir, 'manifest.json')); + await fs.mkdir(path.join(temporaryRoot, 'build', 'mv3-data')); + await fs.writeFile(path.join(temporaryRoot, 'build', 'mv3-data', 'secret.txt'), + '0123456789abcdef'); + const built = await run(process.execPath, [ + '--no-warnings', 'make-rulesets.js', + `output=${outputDir}`, 'platform=chromium', + ], { cwd: buildDir, maxBuffer: 64 * 1024 * 1024 }); + const readJSON = relative => fs.readFile(path.join(outputDir, relative), 'utf8') + .then(text => JSON.parse(text)); + + // Per-ruleset rejected counts, with reasons, in ruleset-details.json and + // in the build log. + const details = await readJSON('rulesets/ruleset-details.json'); + const buildLog = await fs.readFile(path.join(outputDir, 'log.txt'), 'utf8'); + for ( const [ id, expected ] of Object.entries(expectedRejected) ) { + const entry = details.find(a => a.id === id); + assert.equal(entry.rules.rejected, expected.rejected, id); + assert.deepEqual(entry.rules.rejectedReasons, expected.rejectedReasons, id); + assert.equal(entry.filters.converted, expected.converted, id); + const section = buildLog.slice(buildLog.indexOf(`Listset for '${id}':`)); + const reasonLines = Object.entries(expected.rejectedReasons ?? {}) + .map(([ reason, count ]) => `\t\t${reason}: ${count}\n`).join(''); + assert.ok(section.includes(`\tUnsupported: ${expected.rejected}\n${reasonLines}`), + `Build log reports the rejected filters of ${id}`); + } + assert.match(built.stdout, /Unsupported filters in 2 rulesets: 8\n/); + assert.match(built.stdout, /\tunsupported-redirect-rule: 2\n/); + // One line per error, also for an entry with several errors + assert.ok(buildLog.includes('\t\tstrict1p not supported\n' + + '\t\tCan\'t salvage rule with unsupported domain= option: example.*\n')); + assert.equal(buildLog.includes(',\t\t'), false); + + // Unsupported entries are reported, never emitted. + const mainRules = await readJSON('rulesets/main/honesty-a.json'); + assert.equal(JSON.stringify(mainRules).includes('~keep'), false); + assert.equal(mainRules.some(rule => + rule.condition.requestDomains?.includes('rr.fixture.test')), false); + assert.ok(mainRules.some(rule => + rule.action.redirect?.transform?.queryTransform?.removeParams?.[0] === 'utm_source')); + // expandRemoveparamsRule() reports a main_frame-only rule which matches no + // navigation, which must then be discarded + assert.equal(mainRules.some(rule => + rule.action.redirect?.transform?.queryTransform?.removeParams?.[0] === 'r'), false); + const locationRule = mainRules.find(rule => + rule.action.responseHeaders?.[0]?.header === 'location'); + assert.deepEqual(locationRule.condition.requestDomains, [ 'fixture.test' ]); + assert.equal(locationRule.condition.initiatorDomains, undefined); + + // Every redirect resource the runtime compiler accepts is packaged and + // web-accessible, except those listed as unavailable. + const manifest = await readJSON('manifest.json'); + const webAccessible = manifest.web_accessible_resources + .filter(entry => entry.matches.includes('')) + .flatMap(entry => entry.resources); + const generated = await readJSON('rulesets/redirect-resources.json'); + assert.equal(generated.schemaVersion, 1); + assert.deepEqual(generated.unavailable, { + 'click2load.html': 'requires-click-to-load-page', + }); + let packagedCount = 0; + for ( const [ name, metadata ] of redirectResources ) { + const tokens = [ name, ...[ metadata.alias ?? [] ].flat() ]; + if ( name === 'click2load.html' ) { + assert.equal(webAccessible.includes(`web_accessible_resources/${name}`), false); + await assert.rejects(fs.stat(path.join(outputDir, 'web_accessible_resources', name))); + continue; + } + const resourcePath = `web_accessible_resources/${name}`; + assert.ok(webAccessible.includes(resourcePath), `${name} is web-accessible`); + const packaged = await fs.readFile(path.join(outputDir, resourcePath)); + const upstream = await fs.readFile(fromRoot(`src/${resourcePath}`)); + assert.deepEqual(packaged, upstream, `${name} is the upstream resource`); + for ( const token of tokens ) { + assert.equal(generated.resources[token], `/${resourcePath}`, token); + } + packagedCount += 1; + } + assert.equal(packagedCount, redirectResources.size - 1); + for ( const name of [ 'noop-vast4.xml', 'noeval.js', 'google-ima-dai.js', 'noop-0.5s.mp3' ] ) { + assert.ok(webAccessible.includes(`web_accessible_resources/${name}`), name); + } + const warEntry = manifest.web_accessible_resources.find(entry => + entry.resources.includes('web_accessible_resources/noop.js')); + assert.equal(warEntry.use_dynamic_url, true); + + // validate-mv3.mjs accepts the counts and resources of this build (the + // fixture is not a complete extension, so other errors are expected) and + // reports every inconsistency. + // The validator loads js/redirect-resources.js and js/ubo-parser.js from + // the extension, as packaged by the build. + await fs.cp(path.join(buildDir, 'js'), path.join(outputDir, 'js'), { recursive: true }); + await fs.cp(path.join(buildDir, 'lib'), path.join(outputDir, 'lib'), { recursive: true }); + await fs.writeFile(path.join(outputDir, 'package.json'), '{"type":"module"}\n'); + const validate = async ( ) => { + const result = await run(process.execPath, [ + fromRoot('tools/validate-mv3.mjs'), outputDir, + ], { maxBuffer: 64 * 1024 * 1024 }).catch(error => error); + assert.doesNotMatch(result.stderr, /^\s+at .+:\d+:\d+\)?\r?$/m, + 'The validator ran to completion'); + return result.stderr; + }; + const countErrors = /Ruleset|ruleset details|Build log|[Rr]edirect/; + const baseline = await validate(); + assert.doesNotMatch(baseline, countErrors, baseline); + const detailsPath = path.join(outputDir, 'rulesets', 'ruleset-details.json'); + const logPath = path.join(outputDir, 'log.txt'); + const tamper = async (file, edit, expected) => { + const original = await fs.readFile(file, 'utf8'); + await fs.writeFile(file, edit(original)); + try { + assert.match(await validate(), expected); + } finally { + await fs.writeFile(file, original); + } + }; + const editDetails = edit => text => { + const value = JSON.parse(text); + const entry = value.find(a => a.id === 'honesty-a'); + edit(entry.rules, entry.filters); + return JSON.stringify(value); + }; + await tamper(detailsPath, editDetails(rules => { rules.rejected = 0; }), + /Ruleset honesty-a rejected reasons add up to 8, not 0/); + await tamper(detailsPath, editDetails(rules => { + rules.rejected = 0; + rules.rejectedReasons = undefined; + }), /Ruleset honesty-a reports 0 rejected filters, the compiler rejected 8/); + // Entries, not filters: the compiler output has 9 rejected entries + await tamper(detailsPath, editDetails(rules => { + rules.rejected = 9; + rules.rejectedReasons['unsupported-strict-third-party'] += 1; + }), /Ruleset honesty-a reports 9 rejected filters, the compiler rejected 8/); + await tamper(detailsPath, editDetails(rules => { + rules.rejectedReasons = { 'Bad Reason': 8 }; + }), /Ruleset honesty-a has malformed rejected reasons/); + await tamper(detailsPath, editDetails(rules => { rules.plain += 1; }), + /Ruleset honesty-a reports \d+ plain rules, the package has \d+/); + await tamper(detailsPath, editDetails(rules => { rules.total += 1; }), + /Ruleset honesty-a total rule count does not add up/); + await tamper(detailsPath, editDetails((rules, filters) => { filters.converted = 6; }), + /Ruleset honesty-a reports 6 converted filters, the compiler converted 5/); + await tamper(detailsPath, editDetails((rules, filters) => { filters.converted = undefined; }), + /Ruleset honesty-a has an invalid converted filter count/); + await tamper(logPath, text => text.replace('\tUnsupported: 8\n', '\tUnsupported: 0\n'), + /Build log and ruleset details disagree on rejected filters in honesty-a/); + const manifestPath = path.join(outputDir, 'manifest.json'); + await tamper(manifestPath, text => text.replace( + '"web_accessible_resources/noop-vast4.xml",', ''), + /Redirect resource noop-vast4.xml is not web-accessible/); + const generatedPath = path.join(outputDir, 'rulesets', 'redirect-resources.json'); + const editGenerated = edit => text => { + const value = JSON.parse(text); + edit(value); + return JSON.stringify(value); + }; + await tamper(generatedPath, text => text.replace('"click2load.html"', '"click2load-x"'), + /Redirect resource click2load.html must be either packaged or listed as unavailable/); + await tamper(generatedPath, editGenerated(value => { + value.resources['not-a-resource'] = '/web_accessible_resources/noop.js'; + }), /Unknown redirect resource listed: not-a-resource/); + await tamper(generatedPath, editGenerated(value => { + value.unavailable['click2load.html'] = ''; + }), /Unavailable redirect resource click2load.html has no reason/); + + // A redirect resource which cannot be found fails the build. + await fs.rm(path.join(buildDir, 'web_accessible_resources', 'noop.js')); + const missingOutputDir = path.join(temporaryRoot, 'build', 'missing-resource'); + await fs.mkdir(missingOutputDir); + await fs.copyFile(fromRoot('platform/mv3/chromium/manifest.json'), + path.join(missingOutputDir, 'manifest.json')); + const failed = await run(process.execPath, [ + '--no-warnings', 'make-rulesets.js', + `output=${missingOutputDir}`, 'platform=chromium', + ], { cwd: buildDir, maxBuffer: 64 * 1024 * 1024 }).catch(error => error); + assert.notEqual(failed.code ?? 0, 0, 'The build fails'); + assert.match(failed.stderr, /Redirect resource not found: noop\.js/); + await assert.rejects(fs.stat(path.join(missingOutputDir, 'rulesets', 'ruleset-details.json'))); +} finally { + // This is the exact directory returned by mkdtemp for this test only. + await fs.rm(temporaryRoot, { recursive: true, force: true }); +} + +console.log('Stock compiler honesty tests passed'); diff --git a/tools/validate-mv3.mjs b/tools/validate-mv3.mjs index 36b5ca72df6e8..a9df566d492fb 100644 --- a/tools/validate-mv3.mjs +++ b/tools/validate-mv3.mjs @@ -456,6 +456,250 @@ const validateStockBadfilterMetadata = async ruleResources => { } }; +const readExtensionJSON = relativePath => fs.readFile( + path.join(extensionDir, relativePath.replace(/^[/\\]+/, '')), + 'utf8' +).then(text => JSON.parse(text)).catch(( ) => undefined); + +const isReasonCode = value => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value); + +// Count source filters, not compiler entries, as the build does +// (dnrErrorSummary() and dnrConvertedFilterCount() in static-dnr-filtering.js): +// an entry lists the filters it comes from in `_sourceFilters`, an entry +// without sources is one rejected filter, and a filter with any rejected +// entry is not converted. +const sourceFilterCounts = entries => { + const rejected = new Set(); + const converted = new Set(); + let unsourced = 0; + for ( const entry of entries ) { + const sources = Array.isArray(entry?._sourceFilters) + ? entry._sourceFilters + : []; + if ( Boolean(entry?._error) === false ) { + for ( const source of sources ) { converted.add(source); } + } else if ( sources.length === 0 ) { + unsourced += 1; + } else { + for ( const source of sources ) { rejected.add(source); } + } + } + let convertedCount = 0; + for ( const source of converted ) { + if ( rejected.has(source) === false ) { convertedCount += 1; } + } + return { rejected: rejected.size + unsourced, converted: convertedCount }; +}; + +// The per-ruleset counts reported in ruleset-details.json (and in the build +// log when it is present) must describe what is packaged: `plain`, `regex`, +// `urlskip` and `strictblock` count the entries of the matching files, +// `rejected`, the filters which could not be converted into DNR rules, must +// add up from its per-reason breakdown `rejectedReasons`, and development +// builds must agree with the compiler output on `rejected` and on +// `filters.converted`. +const validateRulesetCounts = async ruleResources => { + const rulesetDetails = await readExtensionJSON('rulesets/ruleset-details.json'); + if ( Array.isArray(rulesetDetails) === false ) { + reportError('Ruleset counts cannot be checked: no ruleset details'); + return; + } + const buildLog = await fs.readFile(path.join(extensionDir, 'log.txt'), 'utf8') + .then(text => text.replace(/\r\n/g, '\n')) + .catch(( ) => undefined); + const declaredPaths = new Map(ruleResources.map(resource => + [ resource.id, resource.path ] + )); + const entryCount = async (relativePath, optional) => { + const entries = await readExtensionJSON(relativePath); + if ( Array.isArray(entries) ) { return entries.length; } + if ( entries === undefined && optional ) { return 0; } + }; + for ( const details of rulesetDetails ) { + const id = details?.id; + const rules = details?.rules; + if ( declaredPaths.has(id) === false ) { + reportError(`Ruleset details describe an undeclared ruleset: ${id}`); + continue; + } + if ( isPlainObject(rules) === false ) { + reportError(`Ruleset ${id} has no rule counts`); + continue; + } + const actual = { + plain: await entryCount(declaredPaths.get(id), false), + regex: await entryCount(`rulesets/regex/${id}.json`, true), + urlskip: await entryCount(`rulesets/urlskip/${id}.json`, true), + strictblock: await entryCount(`rulesets/strictblock/${id}.json`, true), + }; + const reported = { + plain: rules.plain, + regex: rules.regex, + urlskip: rules.urlskip ?? 0, + strictblock: rules.strictblock ?? 0, + }; + for ( const [ field, count ] of Object.entries(actual) ) { + if ( count === reported[field] ) { continue; } + reportError( + `Ruleset ${id} reports ${reported[field]} ${field} rules, ` + + `the package has ${count}` + ); + } + if ( rules.total !== rules.plain + rules.regex ) { + reportError(`Ruleset ${id} total rule count does not add up`); + } + const converted = details.filters?.converted; + if ( isNonnegativeInteger(converted) === false ) { + reportError(`Ruleset ${id} has an invalid converted filter count`); + } + const { rejected, rejectedReasons } = rules; + if ( isNonnegativeInteger(rejected) === false ) { + reportError(`Ruleset ${id} has an invalid rejected count`); + continue; + } + if ( rejectedReasons === undefined ) { + if ( rejected !== 0 ) { + reportError(`Ruleset ${id} rejects ${rejected} filters without reasons`); + } + } else { + const entries = isPlainObject(rejectedReasons) + ? Object.entries(rejectedReasons) + : []; + const valid = entries.length !== 0 && entries.every(([ reason, count ]) => + isReasonCode(reason) && Number.isSafeInteger(count) && count > 0 + ); + const sum = entries.reduce((total, [ , count ]) => total + count, 0); + if ( valid === false ) { + reportError(`Ruleset ${id} has malformed rejected reasons`); + } else if ( sum !== rejected ) { + reportError( + `Ruleset ${id} rejected reasons add up to ${sum}, not ${rejected}` + ); + } + } + // Development builds keep the whole compiler output, rejected + // entries included. + const compiled = await readExtensionJSON(`rulesets/debug/${id}.all.json`); + if ( Array.isArray(compiled) ) { + const counts = sourceFilterCounts(compiled); + if ( counts.rejected !== rejected ) { + reportError( + `Ruleset ${id} reports ${rejected} rejected filters, ` + + `the compiler rejected ${counts.rejected}` + ); + } + if ( counts.converted !== converted ) { + reportError( + `Ruleset ${id} reports ${converted} converted filters, ` + + `the compiler converted ${counts.converted}` + ); + } + } + // The build log (absent from packages) must report the same numbers. + if ( buildLog === undefined ) { continue; } + const start = buildLog.indexOf(`\nListset for '${id}':\n`); + const end = buildLog.indexOf('\nListset for \'', start + 1); + const section = buildLog.slice(start, end !== -1 ? end : undefined); + const match = start !== -1 + ? /\n\tUnsupported: (\d+)\n((?:\t\t[a-z0-9-]+: \d+\n)*)/.exec(section) + : null; + if ( match === null ) { + reportError(`Build log has no unsupported count for ruleset ${id}`); + continue; + } + const logged = Object.fromEntries(match[2].split('\n') + .filter(line => line !== '') + .map(line => line.trim().split(': ')) + .map(([ reason, count ]) => [ reason, Number(count) ])); + if ( + Number(match[1]) !== rejected || + JSON.stringify(logged) !== JSON.stringify(rejectedReasons ?? {}) + ) { + reportError(`Build log and ruleset details disagree on rejected filters in ${id}`); + } + } +}; + +// Every name a `redirect=` filter may use (redirect-resources.js, as used by +// the runtime compiler) must be a packaged, web-accessible resource, or be +// listed as unavailable in rulesets/redirect-resources.json. +const validateRedirectResources = async () => { + const generated = await readExtensionJSON('rulesets/redirect-resources.json'); + if ( + generated?.schemaVersion !== 1 || + isPlainObject(generated.resources) === false || + isPlainObject(generated.unavailable) === false + ) { + reportError('rulesets/redirect-resources.json is missing or invalid'); + return; + } + const source = await fs.readFile( + path.join(extensionDir, 'js', 'redirect-resources.js'), 'utf8' + ).catch(( ) => undefined); + let redirectResourceMap; + try { + // Self-contained module: import it from its text, the extension + // directory is not necessarily inside an ES module package. + redirectResourceMap = (await import( + `data:text/javascript,${encodeURIComponent(source)}` + )).default; + } catch { + } + if ( redirectResourceMap instanceof Map === false ) { + reportError('js/redirect-resources.js cannot be loaded'); + return; + } + const webAccessible = new Set(); + for ( const entry of manifest.web_accessible_resources || [] ) { + if ( entry.matches?.includes('') !== true ) { continue; } + for ( const resource of entry.resources || [] ) { + webAccessible.add(resource.replace(/^\/+/, '')); + } + } + const tokens = new Set(); + for ( const [ name, details ] of redirectResourceMap ) { + tokens.add(name); + for ( const alias of [ details?.alias ?? [] ].flat() ) { + tokens.add(alias); + } + } + for ( const token of tokens ) { + const resourcePath = generated.resources[token]; + const unavailable = generated.unavailable[token]; + if ( (resourcePath === undefined) === (unavailable === undefined) ) { + reportError( + `Redirect resource ${token} must be either packaged or ` + + 'listed as unavailable' + ); + continue; + } + if ( unavailable !== undefined ) { + if ( isReasonCode(unavailable) === false ) { + reportError(`Unavailable redirect resource ${token} has no reason`); + } + continue; + } + if ( + typeof resourcePath !== 'string' || + resourcePath.startsWith('/web_accessible_resources/') === false + ) { + reportError(`Redirect resource ${token} has an invalid path`); + continue; + } + await validateFileReference(resourcePath, `Redirect resource ${token}`); + if ( webAccessible.has(resourcePath.slice(1)) === false ) { + reportError(`Redirect resource ${token} is not web-accessible`); + } + } + for ( const token of [ + ...Object.keys(generated.resources), + ...Object.keys(generated.unavailable), + ] ) { + if ( tokens.has(token) ) { continue; } + reportError(`Unknown redirect resource listed: ${token}`); + } +}; + const validateSharedScriptletData = async () => { const readJSON = name => fs.readFile(path.join(extensionDir, `rulesets/${name}.json`), 'utf8') .then(JSON.parse).catch(() => undefined); @@ -629,6 +873,8 @@ if ( Array.isArray(ruleResources) === false || ruleResources.length === 0 ) { } await validateStockPopupCorpora(ruleResources); await validateStockBadfilterMetadata(ruleResources); + await validateRulesetCounts(ruleResources); + await validateRedirectResources(); await validateSharedScriptletData(); }