Files
browser/src/script/Recorder.zig
T

371 lines
14 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/>.
//! In-memory recorder backing the REPL `/save` command: it filters, formats,
//! and scrubs the session's commands into a JavaScript buffer, leaving
//! persistence timing to the caller.
const std = @import("std");
const lp = @import("lightpanda");
const Command = @import("command.zig").Command;
const Recorder = @This();
allocator: std.mem.Allocator,
/// Number of lines appended since the last reset. Bumped only on success.
lines: u32,
/// Whether `const page = new Page();` has been emitted yet. The first recorded
/// command emits it, then every call is a method on `page`.
page_declared: bool,
/// Accumulated JavaScript, returned verbatim by `bytes()`.
content: std.Io.Writer.Allocating,
/// Reused between writes so each line doesn't alloc/free.
buf: std.Io.Writer.Allocating,
/// Reset per write — backs short-lived scrub allocations.
arena: std.heap.ArenaAllocator,
/// The just-emitted `goto` line stays rewritable for exactly one step: a
/// readiness wait recorded next (`Tool.waitsForReadiness`) swaps it for its
/// `waitUntil: "domcontentloaded"` variant; anything else closes the window.
pending_goto: ?PendingGoto,
const PendingGoto = struct {
/// `content` length just before the emitted goto line.
start: usize,
/// Scrubbed replacement line; null when the call carried an explicit waitUntil.
downgraded: ?[]u8,
};
pub fn init(allocator: std.mem.Allocator) Recorder {
return .{
.allocator = allocator,
.lines = 0,
.page_declared = false,
.content = .init(allocator),
.buf = .init(allocator),
.arena = .init(allocator),
.pending_goto = null,
};
}
pub fn deinit(self: *Recorder) void {
self.freePendingGoto();
self.content.deinit();
self.buf.deinit();
self.arena.deinit();
}
pub fn bytes(self: *Recorder) []const u8 {
// A snapshot may be persisted verbatim — its goto line must not change afterwards.
self.freePendingGoto();
return self.content.written();
}
pub fn reset(self: *Recorder) void {
self.freePendingGoto();
self.lines = 0;
self.page_declared = false;
self.content.clearRetainingCapacity();
self.buf.clearRetainingCapacity();
_ = self.arena.reset(.retain_capacity);
}
pub fn record(self: *Recorder, cmd: Command) !void {
if (!cmd.isRecorded()) return;
// `isRecorded` guarantees `.tool_call`.
const tool = cmd.tool_call.tool;
if (self.pending_goto) |pending| {
if (tool.waitsForReadiness()) try self.downgradePendingGoto(pending);
self.freePendingGoto();
}
// The page is born once, up front; every recorded call is then a method
// on it — `goto` async, the rest sync.
if (!self.page_declared) {
try self.content.writer.writeAll("const page = new Page();\n");
self.lines += 1;
self.page_declared = true;
}
self.buf.clearRetainingCapacity();
_ = self.arena.reset(.retain_capacity);
try self.renderCall(cmd, &self.buf.writer);
const start = self.content.written().len;
try self.appendScrubbed();
if (tool == .goto) {
self.pending_goto = .{ .start = start, .downgraded = try self.renderDowngradedGoto(cmd) };
}
}
pub fn recordComment(self: *Recorder, comment: []const u8) !void {
self.freePendingGoto();
self.buf.clearRetainingCapacity();
try writeCommentLines(&self.buf.writer, comment);
try self.appendScrubbed();
}
pub fn recordRaw(self: *Recorder, line: []const u8) !void {
self.freePendingGoto();
self.buf.clearRetainingCapacity();
try self.buf.writer.writeAll(line);
try self.buf.writer.writeByte('\n');
try self.appendScrubbed();
}
fn renderCall(self: *Recorder, cmd: Command, w: *std.Io.Writer) !void {
if (cmd.tool_call.tool.isAsync()) try w.writeAll("await ");
try w.writeAll("page.");
try cmd.formatJs(self.arena.allocator(), w);
try w.writeByte('\n');
}
/// Rendered and scrubbed up front — the command's args die with the caller.
/// Null when there's nothing to downgrade (explicit waitUntil, non-object args).
fn renderDowngradedGoto(self: *Recorder, cmd: Command) !?[]u8 {
// `isRecorded` guaranteed the required `url` arg.
const args = cmd.tool_call.args.?;
if (args != .object) return null;
if (args.object.get("waitUntil") != null) return null;
const aa = self.arena.allocator();
var cloned = try args.object.clone(aa);
try cloned.put(aa, "waitUntil", .{ .string = @tagName(lp.Config.WaitUntil.domcontentloaded) });
self.buf.clearRetainingCapacity();
try self.renderCall(.fromToolCall(.goto, .{ .object = cloned }), &self.buf.writer);
const scrubbed = try lp.tools.reverseSubstituteEnvVars(aa, self.buf.written());
return try self.allocator.dupe(u8, scrubbed);
}
fn downgradePendingGoto(self: *Recorder, pending: PendingGoto) !void {
const line = pending.downgraded orelse return;
// Both variants are exactly one line, so `lines` needs no adjustment.
self.content.shrinkRetainingCapacity(pending.start);
try self.content.writer.writeAll(line);
}
fn freePendingGoto(self: *Recorder) void {
const pending = self.pending_goto orelse return;
if (pending.downgraded) |d| self.allocator.free(d);
self.pending_goto = null;
}
fn appendScrubbed(self: *Recorder) !void {
// Reverse-substitute any LP_* env-var values that snuck in as literals
// (e.g. an agent that retyped a username it saw via getUrl) so the saved
// script stays portable instead of leaking the resolved secret.
_ = self.arena.reset(.retain_capacity);
const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written());
try self.content.writer.writeAll(scrubbed);
self.lines += @intCast(std.mem.count(u8, scrubbed, "\n"));
}
/// Emit each line of `comment` as its own `// ` line, stripping lone CRs.
/// Splitting on newlines is load-bearing: an embedded newline would otherwise
/// smuggle an executable line into the script on replay (e.g.
/// `// foo\ngoto("https://attacker")`).
fn writeCommentLines(w: *std.Io.Writer, comment: []const u8) !void {
var it = std.mem.splitScalar(u8, comment, '\n');
while (it.next()) |line| {
const trimmed = std.mem.trimEnd(u8, line, "\r");
try w.writeAll("// ");
try w.writeAll(trimmed);
try w.writeByte('\n');
}
}
fn parseLine(arena: std.mem.Allocator, line: []const u8) Command {
return Command.parse(arena, line) catch unreachable;
}
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
extern fn unsetenv(name: [*:0]u8) c_int;
test "record filters state-mutating commands and comments" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.record(parseLine(aa, "/goto https://example.com"));
try recorder.record(parseLine(aa, "/tree"));
try recorder.record(parseLine(aa, "/click selector='Login'"));
try recorder.recordComment("search for login");
try std.testing.expectEqualStrings(
"const page = new Page();\nawait page.goto(\"https://example.com\");\npage.click({ selector: \"Login\" });\n// search for login\n",
recorder.bytes(),
);
try std.testing.expectEqual(@as(u32, 4), recorder.lines);
recorder.reset();
try std.testing.expectEqualStrings("", recorder.bytes());
try std.testing.expectEqual(@as(u32, 0), recorder.lines);
try recorder.record(parseLine(aa, "/scroll y=200"));
try std.testing.expectEqualStrings("const page = new Page();\npage.scroll({ y: 200 });\n", recorder.bytes());
try std.testing.expectEqual(@as(u32, 2), recorder.lines);
}
test "recordRaw writes the JS line verbatim" {
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.recordRaw("document.title");
try recorder.recordRaw("window.scrollTo(0, 100)");
try std.testing.expectEqualStrings("document.title\nwindow.scrollTo(0, 100)\n", recorder.bytes());
}
test "record emits multi-line extract as JavaScript" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
const cmd_str = "/extract '{\n \"title\": \"span.title\",\n \"desc\": \"p.description\"\n}'";
try recorder.record(parseLine(aa, cmd_str));
try std.testing.expectEqualStrings(
"const page = new Page();\npage.extract({ title: \"span.title\", desc: \"p.description\" });\n",
recorder.bytes(),
);
}
test "recordComment splits embedded newlines into separate comment lines" {
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
// An attacker-controlled comment trying to smuggle a command must not
// produce an executable line on replay.
try recorder.recordComment("note\n/goto https://attacker\r\nmore");
try std.testing.expectEqualStrings(
"// note\n// /goto https://attacker\n// more\n",
recorder.bytes(),
);
}
test "recordComment scrubs literal LP_* values back to placeholders" {
const var_name = "LP_RECORDER_COMMENT_TEST";
const var_value = "topsecret";
_ = setenv(@constCast(var_name), @constCast(var_value), 1);
defer _ = unsetenv(@constCast(var_name));
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.recordComment("a user noted that their password is topsecret");
try std.testing.expectEqualStrings(
"// a user noted that their password is $LP_RECORDER_COMMENT_TEST\n",
recorder.bytes(),
);
}
test "record downgrades goto before waitForSelector to domcontentloaded" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.record(parseLine(aa, "/goto https://example.com"));
try recorder.record(parseLine(aa, "/waitForSelector .story"));
try std.testing.expectEqualStrings(
"const page = new Page();\nawait page.goto({ url: \"https://example.com\", waitUntil: \"domcontentloaded\" });\npage.waitForSelector(\".story\");\n",
recorder.bytes(),
);
}
test "record keeps the load wait when goto is followed by extract" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.record(parseLine(aa, "/goto https://example.com"));
try recorder.record(parseLine(aa, "/extract '{\"title\": \"h1\"}'"));
try std.testing.expectEqualStrings(
"const page = new Page();\nawait page.goto(\"https://example.com\");\npage.extract({ title: \"h1\" });\n",
recorder.bytes(),
);
}
test "record preserves an explicit waitUntil on goto" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.record(parseLine(aa, "/goto https://example.com waitUntil=networkidle"));
try recorder.record(parseLine(aa, "/waitForSelector .story"));
try std.testing.expectEqualStrings(
"const page = new Page();\nawait page.goto({ url: \"https://example.com\", waitUntil: \"networkidle\" });\npage.waitForSelector(\".story\");\n",
recorder.bytes(),
);
}
test "trailing goto is flushed unmodified by bytes" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.record(parseLine(aa, "/goto https://example.com"));
try std.testing.expectEqualStrings(
"const page = new Page();\nawait page.goto(\"https://example.com\");\n",
recorder.bytes(),
);
}
test "record scrubs literal LP_* values in JavaScript calls" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
const var_name = "LP_RECORDER_COMMAND_TEST";
const var_value = "secret-user";
_ = setenv(@constCast(var_name), @constCast(var_value), 1);
defer _ = unsetenv(@constCast(var_name));
var recorder: Recorder = .init(std.testing.allocator);
defer recorder.deinit();
try recorder.record(parseLine(aa, "/fill selector='#user' value='secret-user'"));
try std.testing.expectEqualStrings(
"const page = new Page();\npage.fill({ selector: \"#user\", value: \"$LP_RECORDER_COMMAND_TEST\" });\n",
recorder.bytes(),
);
}