mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-10 13:22:09 -04:00
251 lines
10 KiB
Zig
251 lines
10 KiB
Zig
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
//! Streams an EasyList-syntax filter list, yielding one supported network
|
|
//! filter per `next` call. Lines we do not support are skipped.
|
|
|
|
const std = @import("std");
|
|
|
|
const NetworkFilter = @import("NetworkFilter.zig");
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const Parser = @This();
|
|
|
|
reader: *std.Io.Reader,
|
|
first_line: bool = true,
|
|
|
|
pub const Error = error{ OutOfMemory, ReadFailed };
|
|
|
|
pub fn init(reader: *std.Io.Reader) Parser {
|
|
return .{ .reader = reader };
|
|
}
|
|
|
|
/// Returns the next network filter, or null at end of list. Allocations come
|
|
/// from `arena`; the returned filter borrows from it and from the reader's
|
|
/// buffer, so it only stays valid until the following call.
|
|
pub fn next(self: *Parser, arena: Allocator) Error!?NetworkFilter {
|
|
while (try self.takeLine()) |raw_line| {
|
|
var stripped: []const u8 = raw_line;
|
|
if (self.first_line) {
|
|
self.first_line = false;
|
|
if (std.mem.startsWith(u8, stripped, "\xEF\xBB\xBF")) {
|
|
stripped = stripped[3..];
|
|
}
|
|
}
|
|
const line = std.mem.trim(u8, stripped, &std.ascii.whitespace);
|
|
|
|
switch (LineClass.fromLine(line)) {
|
|
.empty, .comment, .unsupported => {},
|
|
.network => {
|
|
if (NetworkFilter.parse(arena, line)) |filter| {
|
|
return filter;
|
|
} else |err| switch (err) {
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
// Anything else is a line outside the supported subset
|
|
// or malformed; either way it is not ours to enforce.
|
|
else => {},
|
|
}
|
|
},
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// `Io.Reader.takeDelimiter`, except that a line too long to fit the reader's
|
|
/// buffer is dropped instead of aborting the list. Nothing that long is a
|
|
/// filter we could support, and a single monster line should not cost us the
|
|
/// rest of the file.
|
|
fn takeLine(self: *Parser) error{ReadFailed}!?[]u8 {
|
|
while (true) {
|
|
if (self.reader.takeDelimiter('\n')) |line| {
|
|
return line;
|
|
} else |err| switch (err) {
|
|
error.ReadFailed => return error.ReadFailed,
|
|
error.StreamTooLong => {
|
|
// takeDelimiter leaves the stream untouched on StreamTooLong,
|
|
// so the oversized line still has to be stepped over.
|
|
_ = self.reader.discardDelimiterInclusive('\n') catch |e| switch (e) {
|
|
error.ReadFailed => return error.ReadFailed,
|
|
error.EndOfStream => return null,
|
|
};
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
const LineClass = enum {
|
|
empty,
|
|
comment,
|
|
network,
|
|
/// Recognized syntax we deliberately do not support (AdGuard HTML filtering `$$`).
|
|
unsupported,
|
|
|
|
/// Classifies one trimmed line. There is no cosmetic-separator scan.
|
|
fn fromLine(line: []const u8) LineClass {
|
|
if (line.len == 0) return .empty;
|
|
|
|
switch (line[0]) {
|
|
'!', '#' => return .comment,
|
|
'[' => {
|
|
if (std.ascii.startsWithIgnoreCase(line, "[adblock")) return .comment;
|
|
},
|
|
else => {},
|
|
}
|
|
|
|
if (line[0] == '|' or std.mem.startsWith(u8, line, "@@|")) return .network;
|
|
if (std.mem.indexOf(u8, line, "$$") != null) return .unsupported;
|
|
return .network;
|
|
}
|
|
};
|
|
|
|
const testing = @import("../../testing.zig");
|
|
|
|
test "adblock.Parser: line classification" {
|
|
try testing.expectEqual(.empty, LineClass.fromLine(""));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("! EasyList"));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("[Adblock Plus 2.0]"));
|
|
// Pre-parsing directives are plain comments; their blocks parse
|
|
// unconditionally.
|
|
try testing.expectEqual(.comment, LineClass.fromLine("!#if env_mobile"));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("!#endif"));
|
|
// Every '#'-prefixed line is a comment, including generic cosmetic
|
|
// filters — there is no separator scan.
|
|
try testing.expectEqual(.comment, LineClass.fromLine("# hosts-style comment"));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("#### section"));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("#nosep"));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("## heading text"));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("##.ad-banner"));
|
|
try testing.expectEqual(.comment, LineClass.fromLine("###banner"));
|
|
|
|
try testing.expectEqual(.network, LineClass.fromLine("||ads.example.com^"));
|
|
try testing.expectEqual(.network, LineClass.fromLine("@@|https://example.com/path#frag|"));
|
|
try testing.expectEqual(.network, LineClass.fromLine("0.0.0.0 tracker.com"));
|
|
// Domain-prefixed cosmetic lines classify as network; the filter
|
|
// parser drops them via their '#' (see NetworkFilter tests).
|
|
try testing.expectEqual(.network, LineClass.fromLine("example.com#@#.ad"));
|
|
try testing.expectEqual(.network, LineClass.fromLine("example.com#?#.ad:has-text(x)"));
|
|
try testing.expectEqual(.network, LineClass.fromLine("example.com#$#body { padding: 0 }"));
|
|
|
|
try testing.expectEqual(.unsupported, LineClass.fromLine("example.com$$script[data-x]"));
|
|
}
|
|
|
|
test "adblock.Parser: yields one filter per next() call" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
// BOM-prefixed, no trailing newline on the last line.
|
|
var reader: std.Io.Reader = .fixed("\xEF\xBB\xBF! Title: Streamed\n" ++
|
|
"||ads.example.com^\n" ++
|
|
"example.com##.ad-banner\n" ++
|
|
"||tracker.net^");
|
|
var parser: Parser = .init(&reader);
|
|
|
|
const first = (try parser.next(arena)).?;
|
|
try testing.expectString("ads.example.com", first.hostname);
|
|
|
|
const second = (try parser.next(arena)).?;
|
|
try testing.expectString("tracker.net", second.hostname);
|
|
|
|
// The header, the cosmetic line and the end of the list all yield
|
|
// nothing, and next() keeps returning null once drained.
|
|
try testing.expect(try parser.next(arena) == null);
|
|
try testing.expect(try parser.next(arena) == null);
|
|
}
|
|
|
|
test "adblock.Parser: a line too long for the buffer is skipped" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
var text: std.ArrayList(u8) = .empty;
|
|
defer text.deinit(testing.allocator);
|
|
try text.appendSlice(testing.allocator, "||ads.example.com^\n||");
|
|
try text.appendNTimes(testing.allocator, 'a', 128);
|
|
try text.appendSlice(testing.allocator, ".example.com^\n||tracker.net^\n");
|
|
|
|
// A buffer too small to ever hold the middle line.
|
|
var buf: [64]u8 = undefined;
|
|
var text_reader: std.Io.Reader = .fixed(text.items);
|
|
var reader = text_reader.limited(.unlimited, &buf);
|
|
var parser: Parser = .init(&reader.interface);
|
|
|
|
const first = (try parser.next(arena)).?;
|
|
try testing.expectString("ads.example.com", first.hostname);
|
|
|
|
// The oversized line was stepped over, not treated as end of list.
|
|
const second = (try parser.next(arena)).?;
|
|
try testing.expectString("tracker.net", second.hostname);
|
|
|
|
try testing.expect(try parser.next(arena) == null);
|
|
}
|
|
|
|
test "adblock.Parser: full list" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
var reader: std.Io.Reader = .fixed(
|
|
"[Adblock Plus 2.0]\n" ++
|
|
"! Title: Test List\n" ++
|
|
"! Expires: 4 days (update frequency)\n" ++
|
|
"!\n" ++
|
|
"||ads.example.com^\n" ++
|
|
"||tracker.net^$third-party,script\n" ++
|
|
"@@||cdn.example.com^$script\n" ++
|
|
"-banner-468x60.\n" ++
|
|
"0.0.0.0 telemetry.example.io\n" ++
|
|
"127.0.0.1 localhost\n" ++
|
|
"##.ad-banner\n" ++
|
|
"example.com###sidebar-ad\n" ++
|
|
"example.com#@#.sponsored\n" ++
|
|
"example.com##+js(no-fetch-if, ads)\n" ++
|
|
"||modifier.example.com^$removeparam=utm_source\n" ++
|
|
"||bogus.example.com^$notarealoption\n" ++
|
|
"!#if env_mobile\n" ++
|
|
"||mobile-only.example.com^\n" ++
|
|
"!#else\n" ++
|
|
"||desktop-only.example.com^\n" ++
|
|
"!#endif\n",
|
|
);
|
|
var parser: Parser = .init(&reader);
|
|
|
|
// A filter only borrows the reader's buffer until the next call, so
|
|
// assert on each one as it comes out.
|
|
var count: usize = 0;
|
|
while (try parser.next(arena)) |filter| : (count += 1) {
|
|
switch (count) {
|
|
// "-banner-468x60." is not hostname-shaped (leading '-'), so it
|
|
// stays a plain pattern rather than becoming a hostname filter.
|
|
3 => {
|
|
try testing.expectEqual(.plain, filter.kind);
|
|
try testing.expectString("", filter.hostname);
|
|
},
|
|
6 => try testing.expectString("desktop-only.example.com", filter.hostname),
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// 5 direct network filters + both branches of the !#if block: the
|
|
// directives are comments, their contents parse unconditionally.
|
|
// Everything else in the list — cosmetic lines, the scriptlet, the
|
|
// hosts-file noise, $removeparam and the unknown option — is skipped.
|
|
try testing.expectEqual(7, count);
|
|
}
|