mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
There's ambiguity in the http_client.request() call on whether or not the caller is responsible for freeing the header. It depends how request() fails, and it's impossible for the caller to know. This needs a fundamental fix, but, in the meantime, we get to pick between: a possible leak or a double free. This commit opts for a possible leak. Why? Because overwhelmingly, if request fails, it'll fail at a point where it will handle the free. In those cases where it doesn't then the system is probably in trouble anyways (OOM). (Also, as I was debugging, I noticed that the function.src() debug helper wasn't working, so I fixed it).
399 lines
14 KiB
Zig
399 lines
14 KiB
Zig
// Copyright (C) 2023-2025 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/>.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
const builtin = @import("builtin");
|
|
|
|
const HttpClient = @import("HttpClient.zig");
|
|
|
|
const js = @import("js/js.zig");
|
|
const URL = @import("URL.zig");
|
|
const Frame = @import("Frame.zig");
|
|
const ScriptManagerBase = @import("ScriptManagerBase.zig");
|
|
|
|
const Element = @import("webapi/Element.zig");
|
|
|
|
const log = lp.log;
|
|
const Allocator = std.mem.Allocator;
|
|
const IS_DEBUG = builtin.mode == .Debug;
|
|
|
|
const ScriptManager = @This();
|
|
|
|
// Re-exports so Frame / Context callers don't need to import Base directly.
|
|
pub const Script = ScriptManagerBase.Script;
|
|
pub const ModuleSource = ScriptManagerBase.ModuleSource;
|
|
|
|
base: ScriptManagerBase,
|
|
frame: *Frame,
|
|
|
|
// have we notified the frame that all scripts are loaded (used to fire the
|
|
// "load" event).
|
|
frame_notified_of_completion: bool,
|
|
|
|
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
|
|
var base = ScriptManagerBase.init(allocator, http_client, .{ .frame = frame });
|
|
base.tail_hook = tailHook;
|
|
return .{
|
|
.frame = frame,
|
|
.base = base,
|
|
.frame_notified_of_completion = false,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *ScriptManager) void {
|
|
self.base.deinit();
|
|
}
|
|
|
|
pub fn reset(self: *ScriptManager) void {
|
|
self.base.reset();
|
|
self.frame_notified_of_completion = false;
|
|
}
|
|
|
|
// Frame wrapper uses this to fire documentIsLoaded and scriptsCompletedLoading
|
|
// once Base has finished processing its ready / defer queues.
|
|
pub fn tailHook(base: *ScriptManagerBase) void {
|
|
const self: *ScriptManager = @fieldParentPtr("base", base);
|
|
const frame = self.frame;
|
|
|
|
// When all scripts (normal and deferred) are done loading, the document
|
|
// state changes (this ultimately triggers the DOMContentLoaded event).
|
|
// Page makes this safe to call multiple times.
|
|
frame.documentIsLoaded();
|
|
|
|
if (base.async_scripts.first == null and self.frame_notified_of_completion == false) {
|
|
self.frame_notified_of_completion = true;
|
|
frame.scriptsCompletedLoading();
|
|
}
|
|
}
|
|
|
|
fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
|
|
return self.base.getHeaders();
|
|
}
|
|
|
|
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
|
|
if (script_element._executed) {
|
|
// If a script tag gets dynamically created and added to the dom:
|
|
// document.getElementsByTagName('head')[0].appendChild(script)
|
|
// that script tag will immediately get executed by our scriptAddedCallback.
|
|
// However, if the location where the script tag is inserted happens to be
|
|
// below where processHTMLDoc currently is, then we'll re-run that same script
|
|
// again in processHTMLDoc. This flag is used to let us know if a specific
|
|
// <script> has already been processed.
|
|
return;
|
|
}
|
|
|
|
const element = script_element.asElement();
|
|
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
|
|
// these scripts should only be loaded if we don't support modules
|
|
// but since we do support modules, we can just skip them.
|
|
return;
|
|
}
|
|
|
|
const kind: Script.Extra.FrameExtra.Kind = blk: {
|
|
const script_type = element.getAttributeSafe(comptime .wrap("type")) orelse break :blk .javascript;
|
|
if (script_type.len == 0) {
|
|
break :blk .javascript;
|
|
}
|
|
if (std.ascii.eqlIgnoreCase(script_type, "application/javascript")) {
|
|
break :blk .javascript;
|
|
}
|
|
if (std.ascii.eqlIgnoreCase(script_type, "text/javascript")) {
|
|
break :blk .javascript;
|
|
}
|
|
if (std.ascii.eqlIgnoreCase(script_type, "module")) {
|
|
break :blk .module;
|
|
}
|
|
if (std.ascii.eqlIgnoreCase(script_type, "importmap")) {
|
|
break :blk .importmap;
|
|
}
|
|
|
|
// "type" could be anything, but only the above are ones we need to process.
|
|
// Common other ones are application/json, application/ld+json, text/template
|
|
|
|
return;
|
|
};
|
|
|
|
var handover = false;
|
|
const frame = self.frame;
|
|
|
|
const arena = try frame.getArena(.large, "SM.addFromElement");
|
|
errdefer if (!handover) {
|
|
frame.releaseArena(arena);
|
|
};
|
|
|
|
var source: Script.Source = undefined;
|
|
var remote_url: ?[:0]const u8 = null;
|
|
const base_url = frame.base();
|
|
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
|
|
if (try parseDataURI(arena, src)) |data_uri| {
|
|
source = .{ .@"inline" = data_uri };
|
|
} else {
|
|
remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
|
|
source = .{ .remote = .{} };
|
|
}
|
|
} else {
|
|
var buf = std.Io.Writer.Allocating.init(arena);
|
|
try element.asNode().getChildTextContent(&buf.writer);
|
|
try buf.writer.writeByte(0);
|
|
const data = buf.written();
|
|
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
|
|
if (inline_source.len == 0) {
|
|
// we haven't set script_element._executed = true yet, which is good.
|
|
// If content is appended to the script, we will execute it then.
|
|
frame.releaseArena(arena);
|
|
return;
|
|
}
|
|
source = .{ .@"inline" = inline_source };
|
|
}
|
|
|
|
// Only set _executed (already-started) when we actually have content to execute
|
|
script_element._executed = true;
|
|
const is_inline = source == .@"inline";
|
|
|
|
const mode: Script.Extra.FrameExtra.Mode = blk: {
|
|
if (source == .@"inline") {
|
|
break :blk if (kind == .module) .@"defer" else .normal;
|
|
}
|
|
|
|
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
|
|
break :blk .async;
|
|
}
|
|
|
|
// Check for defer or module (before checking dynamic script default)
|
|
if (kind == .module or element.getAttributeSafe(comptime .wrap("defer")) != null) {
|
|
break :blk .@"defer";
|
|
}
|
|
|
|
// For dynamically-inserted scripts (not from parser), default to async
|
|
// unless async was explicitly set to false (which removes the attribute)
|
|
// and defer was set to true (checked above)
|
|
if (comptime !from_parser) {
|
|
// Script has src and no explicit async/defer attributes
|
|
// Per HTML spec, dynamically created scripts default to async
|
|
break :blk .async;
|
|
}
|
|
|
|
break :blk .normal;
|
|
};
|
|
|
|
const script = try arena.create(Script);
|
|
script.* = .{
|
|
.node = .{},
|
|
.arena = arena,
|
|
.manager = &self.base,
|
|
.source = source,
|
|
.complete = is_inline,
|
|
.status = if (is_inline) 200 else 0,
|
|
.url = remote_url orelse base_url,
|
|
.extra = .{ .frame = .{
|
|
.kind = kind,
|
|
.mode = mode,
|
|
.script_element = script_element,
|
|
.frame = frame,
|
|
} },
|
|
};
|
|
|
|
const is_blocking = mode == .normal;
|
|
if (is_blocking == false) {
|
|
self.base.scriptList(script).append(&script.node);
|
|
}
|
|
|
|
if (remote_url) |url| {
|
|
if (comptime IS_DEBUG) {
|
|
var ls: js.Local.Scope = undefined;
|
|
frame.js.localScope(&ls);
|
|
defer ls.deinit();
|
|
|
|
log.debug(.http, "script queue", .{
|
|
.ctx = ctx,
|
|
.url = remote_url.?,
|
|
.element = element,
|
|
.stack = ls.local.stackTrace() catch "???",
|
|
});
|
|
}
|
|
|
|
const was_evaluating = self.base.is_evaluating;
|
|
self.base.is_evaluating = true;
|
|
defer self.base.is_evaluating = was_evaluating;
|
|
|
|
const headers = try self.getHeaders();
|
|
|
|
if (is_blocking) {
|
|
const response = try self.base.client.syncRequest(arena, .{
|
|
.url = url,
|
|
.method = .GET,
|
|
.frame_id = frame._frame_id,
|
|
.loader_id = frame._loader_id,
|
|
.headers = headers,
|
|
.cookie_jar = &frame._session.cookie_jar,
|
|
.cookie_origin = frame.url,
|
|
.resource_type = .script,
|
|
.notification = frame._session.notification,
|
|
});
|
|
|
|
script.source = .{ .remote = response.body };
|
|
script.status = response.status;
|
|
script.complete = true;
|
|
} else {
|
|
errdefer {
|
|
self.base.scriptList(script).remove(&script.node);
|
|
// Let the outer errdefer handle releasing the arena if client.request fails
|
|
}
|
|
|
|
try self.base.client.request(.{
|
|
.ctx = script,
|
|
.params = .{
|
|
.url = url,
|
|
.method = .GET,
|
|
.frame_id = frame._frame_id,
|
|
.loader_id = frame._loader_id,
|
|
.headers = headers,
|
|
.cookie_jar = &frame._session.cookie_jar,
|
|
.cookie_origin = frame.url,
|
|
.resource_type = .script,
|
|
.notification = frame._session.notification,
|
|
},
|
|
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
|
.header_callback = Script.headerCallback,
|
|
.data_callback = Script.dataCallback,
|
|
.done_callback = Script.doneCallback,
|
|
.error_callback = Script.errorCallback,
|
|
});
|
|
}
|
|
|
|
handover = true;
|
|
}
|
|
|
|
if (is_blocking == false) {
|
|
return;
|
|
}
|
|
|
|
if (script.status == 0) {
|
|
// an error (that we already logged)
|
|
script.deinit();
|
|
return;
|
|
}
|
|
|
|
// could have already been evaluating if this is dynamically added
|
|
const was_evaluating = self.base.is_evaluating;
|
|
self.base.is_evaluating = true;
|
|
defer {
|
|
self.base.is_evaluating = was_evaluating;
|
|
script.deinit();
|
|
}
|
|
|
|
script.eval();
|
|
}
|
|
|
|
pub fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
|
|
const content = script.source.content();
|
|
|
|
const Imports = struct {
|
|
imports: std.json.ArrayHashMap([]const u8),
|
|
};
|
|
|
|
const imports = try std.json.parseFromSliceLeaky(
|
|
Imports,
|
|
self.frame.arena,
|
|
content,
|
|
.{ .allocate = .alloc_always },
|
|
);
|
|
|
|
var iter = imports.imports.map.iterator();
|
|
while (iter.next()) |entry| {
|
|
// > Relative URLs are resolved to absolute URL addresses using the
|
|
// > base URL of the document containing the import map.
|
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_modules_using_import_maps
|
|
const resolved_url = try URL.resolve(
|
|
self.frame.arena,
|
|
self.frame.base(),
|
|
entry.value_ptr.*,
|
|
.{},
|
|
);
|
|
|
|
try self.base.importmap.put(self.frame.arena, entry.key_ptr.*, resolved_url);
|
|
}
|
|
}
|
|
|
|
pub fn staticScriptsDone(self: *ScriptManager) void {
|
|
self.base.staticScriptsDone();
|
|
}
|
|
|
|
// Parses data:[<media-type>][;base64],<data>
|
|
fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {
|
|
if (!std.mem.startsWith(u8, src, "data:")) {
|
|
return null;
|
|
}
|
|
|
|
const uri = src[5..];
|
|
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
|
|
const data = uri[data_starts + 1 ..];
|
|
|
|
const unescaped = try URL.unescape(allocator, data);
|
|
|
|
const metadata = uri[0..data_starts];
|
|
if (std.mem.endsWith(u8, metadata, ";base64") == false) {
|
|
return unescaped;
|
|
}
|
|
|
|
// Forgiving base64 decode per WHATWG spec:
|
|
// https://infra.spec.whatwg.org/#forgiving-base64-decode
|
|
// Step 1: Remove all ASCII whitespace
|
|
var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len);
|
|
for (unescaped) |c| {
|
|
if (!std.ascii.isWhitespace(c)) {
|
|
stripped.appendAssumeCapacity(c);
|
|
}
|
|
}
|
|
const trimmed = std.mem.trimRight(u8, stripped.items, "=");
|
|
|
|
// Length % 4 == 1 is invalid
|
|
if (trimmed.len % 4 == 1) {
|
|
return error.InvalidCharacterError;
|
|
}
|
|
|
|
const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
|
|
const buffer = try allocator.alloc(u8, decoded_size);
|
|
std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError;
|
|
return buffer;
|
|
}
|
|
|
|
const testing = @import("../testing.zig");
|
|
test "DataURI: parse valid" {
|
|
try assertValidDataURI("data:text/javascript; charset=utf-8;base64,Zm9v", "foo");
|
|
try assertValidDataURI("data:text/javascript; charset=utf-8;,foo", "foo");
|
|
try assertValidDataURI("data:,foo", "foo");
|
|
}
|
|
|
|
test "DataURI: parse invalid" {
|
|
try assertInvalidDataURI("atad:,foo");
|
|
try assertInvalidDataURI("data:foo");
|
|
try assertInvalidDataURI("data:");
|
|
}
|
|
|
|
fn assertValidDataURI(uri: []const u8, expected: []const u8) !void {
|
|
defer testing.reset();
|
|
const data_uri = try parseDataURI(testing.arena_allocator, uri) orelse return error.TestFailed;
|
|
try testing.expectEqual(expected, data_uri);
|
|
}
|
|
|
|
fn assertInvalidDataURI(uri: []const u8) !void {
|
|
try testing.expectEqual(null, parseDataURI(undefined, uri));
|
|
}
|