mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-14 15:03:27 -04:00
284 lines
11 KiB
Zig
284 lines
11 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/>.
|
|
|
|
// robots.txt gate for the HttpClient request pipeline. Answers allow/deny
|
|
// from the robot store; on a store miss it parks the transfer, coalesces
|
|
// concurrent requests for the same robots.txt behind a single internal
|
|
// fetch, and resumes (or fails) the parked transfers when it resolves.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
|
|
const URL = @import("../browser/URL.zig");
|
|
const ArenaPool = @import("../ArenaPool.zig");
|
|
|
|
const http = @import("http.zig");
|
|
const Robots = @import("Robots.zig");
|
|
const Network = @import("Network.zig");
|
|
const Transfer = @import("HttpClient.zig").Transfer;
|
|
const SingleFlight = @import("SingleFlight.zig");
|
|
|
|
const log = lp.log;
|
|
|
|
const RobotsGate = @This();
|
|
|
|
network: *Network,
|
|
single_flight: SingleFlight,
|
|
|
|
pub const Result = enum { allowed, blocked, pending };
|
|
|
|
pub fn deinit(self: *RobotsGate) void {
|
|
self.single_flight.deinit();
|
|
}
|
|
|
|
pub fn check(self: *RobotsGate, transfer: *Transfer) !Result {
|
|
const url = transfer.req.url;
|
|
const robots_url = try URL.getRobotsUrl(transfer.arena.allocator(), url);
|
|
|
|
if (self.network.robot_store.get(robots_url)) |robot_entry| {
|
|
switch (robot_entry) {
|
|
.allowed => return .allowed,
|
|
.disallowed => {
|
|
log.warn(.http, "blocked by robots", .{ .url = url });
|
|
return .blocked;
|
|
},
|
|
.present => |robots| {
|
|
if (robots.isAllowed(URL.getPathname(url))) {
|
|
return .allowed;
|
|
}
|
|
log.warn(.http, "blocked by robots", .{ .url = url });
|
|
return .blocked;
|
|
},
|
|
}
|
|
}
|
|
|
|
try self.fetchThenResume(robots_url, transfer);
|
|
return .pending;
|
|
}
|
|
|
|
// A parked transfer is dying out-of-band (abort, owner teardown) — unlink
|
|
// it so the robots.txt resolution doesn't touch freed memory. The map entry
|
|
// stays: the in-flight fetch owns it (the key lives on the fetch's context
|
|
// arena) and still resolves the remaining waiters.
|
|
pub fn remove(self: *RobotsGate, transfer: *Transfer) void {
|
|
self.single_flight.remove(transfer);
|
|
}
|
|
|
|
fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Transfer) !void {
|
|
const client = transfer.client;
|
|
|
|
const result = try self.single_flight.enter(robots_url, transfer, .robots);
|
|
if (result == .queued) return;
|
|
errdefer {
|
|
self.single_flight.discard(robots_url);
|
|
transfer.unpark();
|
|
}
|
|
|
|
const arena = try client.arena_pool.acquire(.small, "RobotsGate.RobotsContext");
|
|
errdefer arena.release();
|
|
|
|
const owned_url = try arena.dupeZ(u8, robots_url);
|
|
const robots_ctx = try arena.create(RobotsContext);
|
|
robots_ctx.* = .{
|
|
.gate = self,
|
|
.buffer = .empty,
|
|
.arena = arena,
|
|
.arena_pool = client.arena_pool,
|
|
.robots_url = owned_url,
|
|
};
|
|
|
|
log.debug(.browser, "fetching robots.txt", .{ .robots_url = owned_url });
|
|
|
|
// Ownerless: no cookies, credentials, headers, or timeout. We attribute to
|
|
// the parent for CDP correlation
|
|
const fetch_transfer = try client.newRequest(.{
|
|
.url = owned_url,
|
|
.method = .GET,
|
|
.internal = true,
|
|
.resource_type = .fetch,
|
|
.frame_id = transfer.req.frame_id,
|
|
.document_frame_id = transfer.req.document_frame_id,
|
|
.loader_id = transfer.req.loader_id,
|
|
.notification = transfer.req.notification,
|
|
.origin = null,
|
|
.credentials_mode = .omit,
|
|
.request_mode = .no_cors,
|
|
.ctx = robots_ctx,
|
|
.header_callback = RobotsContext.headerCallback,
|
|
.data_callback = RobotsContext.dataCallback,
|
|
.done_callback = RobotsContext.doneCallback,
|
|
.error_callback = RobotsContext.errorCallback,
|
|
.shutdown_callback = RobotsContext.shutdownCallback,
|
|
}, null);
|
|
|
|
// From here the fetch owns the pending entry and the context arena. If
|
|
// submit fails it fires error_callback — possibly synchronously, right
|
|
// here — which resolves the waiters (fail-open, may already have resumed
|
|
// `transfer`) and releases the arena. So there is nothing to unwind
|
|
// locally and the errdefers above must not run: swallow the error.
|
|
fetch_transfer.submit() catch {};
|
|
}
|
|
|
|
// The robots.txt fetch resolved: hand every waiter back to the pipeline,
|
|
// each judged against its own path. No store entry (fetch failed, or a 200
|
|
// whose body never got parsed) fails open.
|
|
fn flushPending(self: *RobotsGate, robots_url: []const u8) void {
|
|
var queued = self.single_flight.take(robots_url) orelse return;
|
|
defer queued.deinit(self.single_flight.allocator);
|
|
|
|
const robot_entry = self.network.robot_store.get(robots_url);
|
|
for (queued.items) |transfer| {
|
|
transfer.unpark();
|
|
|
|
const allowed = if (robot_entry) |entry| switch (entry) {
|
|
.allowed => true,
|
|
.disallowed => false,
|
|
.present => |robots| robots.isAllowed(URL.getPathname(transfer.req.url)),
|
|
} else true;
|
|
|
|
if (!allowed) {
|
|
lp.metrics.robots_access.incr(.deny);
|
|
log.warn(.http, "blocked by robots", .{ .url = transfer.req.url });
|
|
transfer.failAsync(error.RobotsBlocked);
|
|
continue;
|
|
}
|
|
// Hand back to the pipeline; the robots gate is the last step
|
|
// before the network. If it fails while we still own the transfer,
|
|
// clean up here.
|
|
lp.metrics.robots_access.incr(.allow);
|
|
transfer.client.resumeAfterRobots(transfer) catch |e| {
|
|
transfer.abortPipelineError(e);
|
|
};
|
|
}
|
|
}
|
|
|
|
const RobotsContext = struct {
|
|
gate: *RobotsGate,
|
|
arena: *lp.Arena,
|
|
arena_pool: *ArenaPool,
|
|
robots_url: [:0]const u8,
|
|
buffer: std.ArrayList(u8),
|
|
status: u16 = 0,
|
|
|
|
fn headerCallback(transfer: *Transfer) anyerror!Transfer.HeaderResult {
|
|
const self: *RobotsContext = @ptrCast(@alignCast(transfer.req.ctx));
|
|
if (transfer.res.header) |hdr| {
|
|
log.debug(.browser, "robots status", .{ .status = hdr.status, .robots_url = self.robots_url });
|
|
self.status = hdr.status;
|
|
}
|
|
lp.metrics.robots_status.incr(http.statusCategory(self.status));
|
|
if (transfer.getContentLength()) |cl| {
|
|
try self.buffer.ensureTotalCapacityPrecise(self.arena.allocator(), cl);
|
|
}
|
|
return .proceed;
|
|
}
|
|
|
|
fn dataCallback(transfer: *Transfer, data: []const u8) anyerror!void {
|
|
const self: *RobotsContext = @ptrCast(@alignCast(transfer.req.ctx));
|
|
if (self.status == 200) {
|
|
try self.buffer.appendSlice(self.arena.allocator(), data);
|
|
}
|
|
}
|
|
|
|
fn doneCallback(ctx_ptr: *anyopaque) anyerror!void {
|
|
const self: *RobotsContext = @ptrCast(@alignCast(ctx_ptr));
|
|
const robots_url = self.robots_url;
|
|
const network = self.gate.network;
|
|
|
|
switch (self.status) {
|
|
200 => {
|
|
if (self.buffer.items.len > 0) {
|
|
const robots: ?Robots = network.robot_store.robotsFromBytes(
|
|
network.config.http_headers.user_agent,
|
|
self.buffer.items,
|
|
) catch |err| blk: {
|
|
// We only return an error if an allocation or something fails.
|
|
// Our parser does already leniently handle malformed input and takes whichever rules it can parse.
|
|
// On this case of an allocation failure, it is our fault so we put it as disallowed.
|
|
log.warn(.browser, "error while parsing robots.txt", .{ .robots_url = robots_url, .err = err });
|
|
try network.robot_store.putDisallowed(robots_url);
|
|
break :blk null;
|
|
};
|
|
if (robots) |r| {
|
|
try network.robot_store.put(robots_url, r);
|
|
}
|
|
} else {
|
|
// Empty robots.txt means we can short-circuit the allowed path.
|
|
try network.robot_store.putAllowed(robots_url);
|
|
}
|
|
},
|
|
// Unauthorized/Forbidden: treat as fully disallowed since we can't verify permissions.
|
|
401, 403 => {
|
|
log.debug(.http, "robots.txt access denied", .{
|
|
.url = robots_url,
|
|
.status = self.status,
|
|
});
|
|
try network.robot_store.putDisallowed(robots_url);
|
|
},
|
|
// RFC9309: Unavailable (400-499) means that we may access any resources on the server.
|
|
400, 402, 404...499 => {
|
|
log.debug(.http, "robots.txt unavailable", .{ .url = robots_url });
|
|
try network.robot_store.putAllowed(robots_url);
|
|
},
|
|
// RFC9309: Unreachable (500-599) means that we are completely disallowed.
|
|
500...599 => {
|
|
log.warn(.http, "robots.txt unreachable", .{
|
|
.url = robots_url,
|
|
.status = self.status,
|
|
});
|
|
try network.robot_store.putDisallowed(robots_url);
|
|
},
|
|
else => {
|
|
log.debug(.http, "unexpected status on robots", .{
|
|
.url = robots_url,
|
|
.status = self.status,
|
|
});
|
|
try network.robot_store.putDisallowed(robots_url);
|
|
},
|
|
}
|
|
|
|
// If anything above threw, error_callback fires next and resolves
|
|
// instead — resolve() must run exactly once.
|
|
self.resolve();
|
|
}
|
|
|
|
fn errorCallback(ctx_ptr: *anyopaque, err: anyerror) void {
|
|
const self: *RobotsContext = @ptrCast(@alignCast(ctx_ptr));
|
|
|
|
log.warn(.http, "robots fetch failed", .{ .err = err });
|
|
self.resolve();
|
|
}
|
|
|
|
fn shutdownCallback(ctx_ptr: *anyopaque) void {
|
|
const self: *RobotsContext = @ptrCast(@alignCast(ctx_ptr));
|
|
|
|
log.debug(.http, "robots fetch shutdown", .{});
|
|
const gate = self.gate;
|
|
const arena = self.arena;
|
|
gate.single_flight.discard(self.robots_url);
|
|
arena.release();
|
|
}
|
|
|
|
fn resolve(self: *RobotsContext) void {
|
|
const gate = self.gate;
|
|
const arena = self.arena;
|
|
gate.flushPending(self.robots_url);
|
|
arena.release();
|
|
}
|
|
};
|