diff --git a/src/Notification.zig b/src/Notification.zig index e29d5fa32..b710cf855 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -96,6 +96,8 @@ const EventListeners = struct { model_context_tool_added: List = .{}, model_context_tool_removed: List = .{}, cookie_changed: List = .{}, + download_will_begin: List = .{}, + download_progress: List = .{}, }; const Events = union(enum) { @@ -123,6 +125,8 @@ const Events = union(enum) { model_context_tool_added: *const ModelContextToolEvent, model_context_tool_removed: *const ModelContextToolEvent, cookie_changed: *const CookieChanged, + download_will_begin: *const DownloadWillBegin, + download_progress: *const DownloadProgress, }; const EventType = std.meta.FieldEnum(Events); @@ -271,6 +275,34 @@ pub const CookieChanged = struct { pub const Kind = enum { changed, deleted }; }; +// Emitted by Frame when a navigation response is treated as a file download +// (Content-Disposition: attachment) under an `allow`/`allowAndName` +// `Browser.setDownloadBehavior`. The CDP page domain turns this into a +// `Page.downloadWillBegin` event. All fields point at Frame-owned memory that +// outlives the synchronous dispatch. +pub const DownloadWillBegin = struct { + frame_id: u32, + guid: []const u8, + url: []const u8, + suggested_filename: []const u8, +}; + +// Emitted by Frame as a download is written to disk: once when it starts +// (`.in_progress`) and once when the body is fully written (`.completed`). +// The CDP browser domain turns this into a `Browser.downloadProgress` event. +pub const DownloadProgress = struct { + guid: []const u8, + total_bytes: u64, + received_bytes: u64, + state: State, + + pub const State = enum { + in_progress, + completed, + canceled, + }; +}; + pub const DialogResponse = struct { accept: bool = false, // Set when the CDP client sent a `promptText` with `accept: true`. Memory diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index b037daa93..77479e87b 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1198,9 +1198,167 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu }); } + // If the response is a file download, stream its body to disk instead of + // parsing it as a page. This sets _parse_state to .download, which the + // data/done callbacks below special-case. + _ = try self.maybeStartDownload(response); + return .proceed; } +// Returns true when the response was set up as a file download. A response is +// treated as a download when Browser.setDownloadBehavior opted in +// (allow/allowAndName) and the response carries Content-Disposition: attachment. +// See issue #2701. +fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool { + const session = self._session; + switch (session.download_behavior) { + .allow, .allow_and_name => {}, + .default, .deny => return false, + } + + const disposition = blk: { + var it = response.headerIterator(); + while (it.next()) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) { + break :blk hdr.value; + } + } + return false; + }; + if (isAttachment(disposition) == false) { + return false; + } + + const download_path = session.download_path orelse { + log.warn(.frame, "download without downloadPath", .{ .url = self.url }); + return false; + }; + + var guid_buf: [36]u8 = undefined; + @import("../id.zig").uuidv4(&guid_buf); + const guid = try self.arena.dupe(u8, &guid_buf); + + const suggested = dispositionFilename(disposition) orelse urlBasename(self.url) orelse guid; + const suggested_filename = try self.arena.dupe(u8, suggested); + + // allowAndName stores the file under its guid; allow uses the suggested name. + const on_disk_name = switch (session.download_behavior) { + .allow_and_name => guid, + else => suggested_filename, + }; + + std.fs.cwd().makePath(download_path) catch |err| { + log.err(.frame, "download makePath", .{ .err = err, .path = download_path }); + return false; + }; + var dir = std.fs.cwd().openDir(download_path, .{}) catch |err| { + log.err(.frame, "download openDir", .{ .err = err, .path = download_path }); + return false; + }; + defer dir.close(); + const file = dir.createFile(on_disk_name, .{ .truncate = true }) catch |err| { + log.err(.frame, "download createFile", .{ .err = err, .name = on_disk_name }); + return false; + }; + + const total: ?u64 = if (response.contentLength()) |cl| cl else null; + + self._parse_state = .{ .download = .{ + .guid = guid, + .file = file, + .filename = suggested_filename, + .received = 0, + .total = total, + } }; + + if (session.download_events_enabled) { + session.notification.dispatch(.download_will_begin, &.{ + .frame_id = self._frame_id, + .guid = guid, + .url = self.url, + .suggested_filename = suggested_filename, + }); + session.notification.dispatch(.download_progress, &.{ + .guid = guid, + .total_bytes = total orelse 0, + .received_bytes = 0, + .state = .in_progress, + }); + } + + return true; +} + +// True when a Content-Disposition value's type token is "attachment". +fn isAttachment(disposition: []const u8) bool { + const semi = std.mem.indexOfScalar(u8, disposition, ';') orelse disposition.len; + const token = std.mem.trim(u8, disposition[0..semi], " \t"); + return std.ascii.eqlIgnoreCase(token, "attachment"); +} + +// Extracts the filename from a Content-Disposition value, handling the quoted, +// unquoted, and RFC 5987 (filename*=charset''value) forms. Path components are +// stripped so the result is always a bare basename. +fn dispositionFilename(disposition: []const u8) ?[]const u8 { + // Prefer the extended filename*= form when present, per RFC 6266. + if (paramValue(disposition, "filename*")) |ext| { + // charset'lang'value — take everything after the second quote. + if (std.mem.indexOfScalar(u8, ext, '\'')) |first| { + if (std.mem.indexOfScalarPos(u8, ext, first + 1, '\'')) |second| { + return basename(ext[second + 1 ..]); + } + } + return basename(ext); + } + if (paramValue(disposition, "filename")) |name| { + return basename(name); + } + return null; +} + +// Returns the (optionally quoted) value of `key=` within a parameter list. +fn paramValue(disposition: []const u8, key: []const u8) ?[]const u8 { + var rest = disposition; + while (std.mem.indexOfScalar(u8, rest, ';')) |semi| { + const param = std.mem.trim(u8, rest[semi + 1 ..][0 .. (std.mem.indexOfScalarPos(u8, rest, semi + 1, ';') orelse rest.len) - semi - 1], " \t"); + if (std.mem.indexOfScalar(u8, param, '=')) |eq| { + const name = std.mem.trim(u8, param[0..eq], " \t"); + if (std.ascii.eqlIgnoreCase(name, key)) { + var value = std.mem.trim(u8, param[eq + 1 ..], " \t"); + if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') { + value = value[1 .. value.len - 1]; + } + if (value.len > 0) { + return value; + } + } + } + rest = rest[semi + 1 ..]; + } + return null; +} + +// Strips any directory components, guarding against path traversal. +fn basename(name: []const u8) ?[]const u8 { + var out = name; + if (std.mem.lastIndexOfAny(u8, out, "/\\")) |i| { + out = out[i + 1 ..]; + } + if (out.len == 0 or std.mem.eql(u8, out, ".") or std.mem.eql(u8, out, "..")) { + return null; + } + return out; +} + +// Derives a filename from a URL's last path segment, ignoring query/fragment. +fn urlBasename(url: []const u8) ?[]const u8 { + var path = url; + if (std.mem.indexOfScalar(u8, path, '?')) |i| path = path[0..i]; + if (std.mem.indexOfScalar(u8, path, '#')) |i| path = path[0..i]; + return basename(path); +} + fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { var self: *Frame = @ptrCast(@alignCast(response.ctx)); @@ -1279,6 +1437,13 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { } }, .raw, .image => |*buf| try buf.appendSlice(self.arena, data), + .download => |*download| { + download.file.writeAll(data) catch |err| { + log.err(.frame, "download write", .{ .err = err, .guid = download.guid }); + return; + }; + download.received += data.len; + }, .pre => unreachable, .complete => unreachable, .err => unreachable, @@ -1373,6 +1538,30 @@ fn frameDoneCallback(ctx: *anyopaque) !void { self._parse_state = .complete; self.documentIsComplete(); }, + .download => |*download| { + download.file.close(); + + // Capture before invalidating the union below. + const guid = download.guid; + const received = download.received; + const total = download.total orelse download.received; + self._parse_state = .complete; + + const session = self._session; + if (session.download_events_enabled) { + session.notification.dispatch(.download_progress, &.{ + .guid = guid, + .total_bytes = total, + .received_bytes = received, + .state = .completed, + }); + } + + // The body went to disk; commit an empty document so the frame + // navigation still completes cleanly (mirrors the .raw path). + parser.parse(""); + self.documentIsComplete(); + }, else => unreachable, } } @@ -3793,15 +3982,34 @@ const ParseState = union(enum) { image: std.ArrayList(u8), raw: std.ArrayList(u8), raw_done: []const u8, + download: Download, fn deinit(self: *ParseState, frame: *Frame) void { switch (self.*) { .html => |html| frame.releaseArena(html.arena), + // Only reached when a frame is torn down mid-download (the normal + // completion path in frameDoneCallback already closes the file and + // transitions to .complete). + .download => |*download| download.file.close(), else => {}, } } }; +// An in-flight file download (Content-Disposition: attachment under an +// allow/allowAndName Browser.setDownloadBehavior). The response body is +// streamed straight to `file` rather than parsed as a page. See issue #2701. +const Download = struct { + // uuidv4, arena-owned. Matches the guid reported in the CDP events. + guid: []const u8, + file: std.fs.File, + // suggested filename surfaced to the client, arena-owned. + filename: []const u8, + received: u64, + // from Content-Length, when the response advertised one. + total: ?u64, +}; + const LoadState = enum { // waiting for the main HTML waiting, @@ -4644,6 +4852,35 @@ fn asUint(comptime string: anytype) std.meta.Int( } const testing = @import("../testing.zig"); + +test "Frame: isAttachment" { + try testing.expectEqual(true, isAttachment("attachment")); + try testing.expectEqual(true, isAttachment("attachment; filename=\"a.csv\"")); + try testing.expectEqual(true, isAttachment(" ATTACHMENT ; filename=a.csv")); + try testing.expectEqual(false, isAttachment("inline")); + try testing.expectEqual(false, isAttachment("inline; filename=a.csv")); + try testing.expectEqual(false, isAttachment("")); +} + +test "Frame: dispositionFilename" { + try testing.expectEqualSlices(u8, "report.csv", dispositionFilename("attachment; filename=\"report.csv\"").?); + try testing.expectEqualSlices(u8, "report.csv", dispositionFilename("attachment; filename=report.csv").?); + try testing.expectEqualSlices(u8, "r e.csv", dispositionFilename("attachment; filename=\"r e.csv\"").?); + // RFC 5987 extended form is preferred over the plain filename when present + // (the value is taken verbatim after the charset'lang' prefix). + try testing.expectEqualSlices(u8, "extended.txt", dispositionFilename("attachment; filename=\"fallback.txt\"; filename*=UTF-8''extended.txt").?); + try testing.expect(dispositionFilename("attachment") == null); + // Path components are stripped to guard against traversal. + try testing.expectEqualSlices(u8, "evil.sh", dispositionFilename("attachment; filename=\"../../evil.sh\"").?); + try testing.expect(dispositionFilename("attachment; filename=\"..\"") == null); +} + +test "Frame: urlBasename" { + try testing.expectEqualSlices(u8, "report.csv", urlBasename("http://x.com/a/b/report.csv").?); + try testing.expectEqualSlices(u8, "report.csv", urlBasename("http://x.com/report.csv?v=1#x").?); + try testing.expect(urlBasename("http://x.com/") == null); +} + test "WebApi: Frame" { const filter: testing.LogFilter = .init(&.{.http}); defer filter.deinit(); diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index ecd831938..9e690aeee 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -164,7 +164,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !TickResult { const http_client = self.http_client; switch (frame._parse_state) { - .pre, .raw, .text, .image => { + .pre, .raw, .text, .image, .download => { // The main frame hasn't started/finished navigating. // There's no JS to run, and no reason to run the scheduler // — unless we're the CDP worker, in which case we want diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 1070b044d..6c1ac961c 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -112,6 +112,24 @@ load_external_stylesheets: bool = false, /// timeout. cancel_hook: ?CancelHook = null, +// Download handling configured via the `Browser.setDownloadBehavior` CDP +// method (see issue #2701). When `download_behavior` is `.allow` or +// `.allow_and_name`, a navigation whose response carries +// `Content-Disposition: attachment` is written to `download_path` instead +// of being parsed as a page, and (when `download_events_enabled` is set) +// `Page.downloadWillBegin` / `Browser.downloadProgress` events are emitted. +// `download_path` is duped into the Session arena. +download_behavior: DownloadBehavior = .default, +download_path: ?[]const u8 = null, +download_events_enabled: bool = false, + +pub const DownloadBehavior = enum { + default, + allow, + allow_and_name, + deny, +}; + pub const CancelHook = struct { context: *anyopaque, check: *const fn (*anyopaque) bool, diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 88e1f1345..cd8fd437e 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -526,6 +526,10 @@ pub const BrowserContext = struct { http_proxy_changed: bool = false, user_agent_changed: bool = false, + // True once we've registered for the download notifications, so repeated + // Browser.setDownloadBehavior calls don't add duplicate listeners. + download_events_registered: bool = false, + // Extra headers to add to all requests. extra_headers: std.ArrayList([*c]const u8) = .empty, @@ -797,6 +801,36 @@ pub const BrowserContext = struct { self.notification.unregister(.runtime_console_message, self); } + // Registers for the download notifications dispatched by Frame when a + // navigation is treated as a file download. Idempotent. See issue #2701. + pub fn downloadEventsEnable(self: *BrowserContext) !void { + if (self.download_events_registered) { + return; + } + try self.notification.register(.download_will_begin, self, onDownloadWillBegin); + try self.notification.register(.download_progress, self, onDownloadProgress); + self.download_events_registered = true; + } + + pub fn downloadEventsDisable(self: *BrowserContext) void { + if (self.download_events_registered == false) { + return; + } + self.notification.unregister(.download_will_begin, self); + self.notification.unregister(.download_progress, self); + self.download_events_registered = false; + } + + pub fn onDownloadWillBegin(ctx: *anyopaque, msg: *const Notification.DownloadWillBegin) !void { + const self: *BrowserContext = @ptrCast(@alignCast(ctx)); + return @import("domains/page.zig").downloadWillBegin(self, msg); + } + + pub fn onDownloadProgress(ctx: *anyopaque, msg: *const Notification.DownloadProgress) !void { + const self: *BrowserContext = @ptrCast(@alignCast(ctx)); + return @import("domains/browser.zig").downloadProgress(self, msg); + } + pub fn webmcpEnable(self: *BrowserContext) !void { try self.notification.register(.model_context_tool_added, self, onModelContextToolAdded); try self.notification.register(.model_context_tool_removed, self, onModelContextToolRemoved); diff --git a/src/cdp/domains/browser.zig b/src/cdp/domains/browser.zig index b384c5616..42ae7d57e 100644 --- a/src/cdp/domains/browser.zig +++ b/src/cdp/domains/browser.zig @@ -19,6 +19,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const CDP = @import("../CDP.zig"); +const Session = @import("../../browser/Session.zig"); +const Notification = @import("../../Notification.zig"); const log = lp.log; const PermissionState = @import("../../browser/webapi/Permissions.zig").State; @@ -73,18 +75,61 @@ fn getVersion(cmd: *CDP.Command) !void { }, .{ .include_session_id = false }); } -// TODO: noop method +// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior fn setDownloadBehavior(cmd: *CDP.Command) !void { - // const params = (try cmd.params(struct { - // behavior: []const u8, - // browserContextId: ?[]const u8 = null, - // downloadPath: ?[]const u8 = null, - // eventsEnabled: ?bool = null, - // })) orelse return error.InvalidParams; + const params = (try cmd.params(struct { + behavior: []const u8, + browserContextId: ?[]const u8 = null, + downloadPath: ?[]const u8 = null, + eventsEnabled: ?bool = null, + })) orelse return error.InvalidParams; + + const behavior: Session.DownloadBehavior = if (std.mem.eql(u8, params.behavior, "allow")) + .allow + else if (std.mem.eql(u8, params.behavior, "allowAndName")) + .allow_and_name + else if (std.mem.eql(u8, params.behavior, "deny")) + .deny + else if (std.mem.eql(u8, params.behavior, "default")) + .default + else + return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const session = bc.session; + + session.download_behavior = behavior; + // downloadPath comes from the (transient) command arena; persist it on the + // session arena since Frame reads it on later navigations. + session.download_path = if (params.downloadPath) |p| try session.arena.dupe(u8, p) else null; + session.download_events_enabled = params.eventsEnabled orelse false; + + if (session.download_events_enabled) { + try bc.downloadEventsEnable(); + } else { + bc.downloadEventsDisable(); + } return cmd.sendResult(null, .{ .include_session_id = false }); } +// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#event-downloadProgress +// Dispatched by Frame as a download is written to disk (see issue #2701). +pub fn downloadProgress(bc: *CDP.BrowserContext, msg: *const Notification.DownloadProgress) !void { + const session_id = bc.session_id orelse return; + + return bc.cdp.sendEvent("Browser.downloadProgress", .{ + .guid = msg.guid, + .totalBytes = msg.total_bytes, + .receivedBytes = msg.received_bytes, + .state = switch (msg.state) { + .in_progress => "inProgress", + .completed => "completed", + .canceled => "canceled", + }, + }, .{ .session_id = session_id }); +} + fn getWindowForTarget(cmd: *CDP.Command) !void { // const params = (try cmd.params(struct { // targetId: ?[]const u8 = null, @@ -230,3 +275,97 @@ test "cdp.browser: grant/set/reset permissions reach navigator.permissions" { try ctx.expectSentResult(null, .{ .id = 42, .session_id = null }); try testing.expectEqual(@as(usize, 0), browser.permissions.count()); } + +test "cdp.browser: setDownloadBehavior stores config on the session" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .session_id = "SID-CFG" }); + + try ctx.processMessage(.{ + .id = 34, + .method = "Browser.setDownloadBehavior", + .params = .{ + .behavior = "allowAndName", + .downloadPath = "/tmp/lp-downloads", + .eventsEnabled = true, + }, + }); + try ctx.expectSentResult(null, .{ .id = 34, .session_id = null }); + + try testing.expectEqual(.allow_and_name, bc.session.download_behavior); + try testing.expectEqualSlices(u8, "/tmp/lp-downloads", bc.session.download_path.?); + try testing.expect(bc.session.download_events_enabled); + try testing.expect(bc.download_events_registered); + + // Flipping back to default tears the registration down again. + try ctx.processMessage(.{ + .id = 35, + .method = "Browser.setDownloadBehavior", + .params = .{ .behavior = "default" }, + }); + try testing.expectEqual(.default, bc.session.download_behavior); + try testing.expect(bc.session.download_path == null); + try testing.expect(bc.session.download_events_enabled == false); + try testing.expect(bc.download_events_registered == false); +} + +test "cdp.browser: setDownloadBehavior rejects an unknown behavior" { + var ctx = try testing.context(); + defer ctx.deinit(); + + _ = try ctx.loadBrowserContext(.{ .session_id = "SID-BAD" }); + + try ctx.processMessage(.{ + .id = 36, + .method = "Browser.setDownloadBehavior", + .params = .{ .behavior = "sometimes" }, + }); + try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 36 }); +} + +test "cdp.browser: setDownloadBehavior writes an attachment to disk and emits events" { + var ctx = try testing.context(); + defer ctx.deinit(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const download_path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(download_path); + + const bc = try ctx.loadBrowserContext(.{ + .session_id = "SID-DL", + .target_id = "TID-00000000DL".*, + }); + + try ctx.processMessage(.{ + .id = 37, + .method = "Browser.setDownloadBehavior", + .params = .{ + .behavior = "allow", + .downloadPath = download_path, + .eventsEnabled = true, + }, + }); + try ctx.expectSentResult(null, .{ .id = 37, .session_id = null }); + + const frame = try bc.session.createPage(); + try frame.navigate("http://127.0.0.1:9582/download/report.csv", .{}); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + // The guid is random, so it's omitted from these subset matches. + try ctx.expectSentEvent("Page.downloadWillBegin", .{ + .url = "http://127.0.0.1:9582/download/report.csv", + .suggestedFilename = "report.csv", + }, .{ .session_id = "SID-DL" }); + try ctx.expectSentEvent("Browser.downloadProgress", .{ + .state = "completed", + .totalBytes = 30, + .receivedBytes = 30, + }, .{ .session_id = "SID-DL" }); + + const written = try tmp.dir.readFileAlloc(testing.allocator, "report.csv", 1024); + defer testing.allocator.free(written); + try testing.expectEqualSlices(u8, "col1,col2\nhello,world\n42,1337\n", written); +} diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 2a5808e1d..8be988d36 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -806,6 +806,20 @@ pub fn frameNetworkAlmostIdle(bc: *CDP.BrowserContext, event: *const Notificatio return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.loader_id)); } +// https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-downloadWillBegin +// Dispatched by Frame when a navigation response is treated as a file download +// (see issue #2701). +pub fn downloadWillBegin(bc: *CDP.BrowserContext, event: *const Notification.DownloadWillBegin) !void { + const session_id = bc.session_id orelse return; + + return bc.cdp.sendEvent("Page.downloadWillBegin", .{ + .frameId = &id.toFrameId(event.frame_id), + .guid = event.guid, + .url = event.url, + .suggestedFilename = event.suggested_filename, + }, .{ .session_id = session_id }); +} + fn sendPageLifecycle(bc: *CDP.BrowserContext, name: []const u8, timestamp: u64, frame_id: []const u8, loader_id: []const u8) !void { // detachTarget could be called, in which case, we still have a frame doing // things, but no session. diff --git a/src/testing.zig b/src/testing.zig index 1727b95b8..6939e6f5d 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -821,6 +821,17 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/download/report.csv")) { + // A file download: Content-Disposition: attachment drives the + // Browser.setDownloadBehavior path (issue #2701). + return req.respond("col1,col2\nhello,world\n42,1337\n", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/csv" }, + .{ .name = "Content-Disposition", .value = "attachment; filename=\"report.csv\"" }, + }, + }); + } + if (std.mem.startsWith(u8, path, "/src/browser/tests/")) { // strip off leading / so that it's relative to CWD return TestHTTPServer.sendFile(req, path[1..]);