wpt: improve webcrypto WPT results

Adds a proper QuoteExceededError WebApi.

Handles more invalid parameters.

The most significant change is that types which return a promise don't return
an error, they reject. This is an issue that we run into a lot, and this
commit has started to work on a more generic (i.e. in the bridge) solution.
This commit is contained in:
Karl Seguin
2026-07-31 18:19:58 +08:00
parent 392bb4c772
commit b415cbe944
9 changed files with 144 additions and 9 deletions

View File

@@ -605,10 +605,29 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
};
};
if (comptime returnsPromise(F) and @TypeOf(info) == FunctionCallbackInfo) {
// An operation that returns a promise must not throw. It rejects.
const resolver = js.PromiseResolver.init(&err_local);
resolver.rejectValue(.{ .local = &err_local, .handle = js_err }) catch |reject_err| {
log.err(.bug, "handleError reject", .{ .err = reject_err });
};
info.getReturnValue().set(resolver.promise().toValue());
return;
}
const js_exception = isolate.throwException(js_err);
info.getReturnValue().setValueHandle(js_exception);
}
fn returnsPromise(comptime F: type) bool {
const R = @typeInfo(F).@"fn".return_type orelse return false;
const payload = switch (@typeInfo(R)) {
.error_union => |eu| eu.payload,
else => R,
};
return payload == js.Promise;
}
// Convert a Zig error to a DOMException. If the error is unknown, return null.
fn domExceptionToJs(local: *const Local, err: anyerror) ?*const v8.Value {
const DOMException = @import("../webapi/DOMException.zig");

View File

@@ -108,13 +108,19 @@ pub fn rejectError(
fn _reject(self: PromiseResolver, value: anytype) !void {
const local = self.local;
const js_val = try local.zigValueToJs(value, .{});
try self.rejectValue(js_val);
local.runMicrotasks();
}
/// Rejects with an already-built JS value, without running a microtask
/// checkpoint. For use while the calling script is still on the stack, where
/// draining the queue would run unrelated reactions mid-script.
pub fn rejectValue(self: PromiseResolver, value: js.Value) !void {
var out: v8.MaybeBool = undefined;
v8.v8__Promise__Resolver__Reject(self.handle, local.handle, js_val.handle, &out);
v8.v8__Promise__Resolver__Reject(self.handle, self.local.handle, value.handle, &out);
if (!out.has_value or !out.value) {
return error.FailedToRejectPromise;
}
local.runMicrotasks();
}
pub fn persist(self: PromiseResolver) !Global {

View File

@@ -962,6 +962,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/DocumentType.zig"),
@import("../webapi/ShadowRoot.zig"),
@import("../webapi/DOMException.zig"),
@import("../webapi/QuotaExceededError.zig"),
@import("../webapi/DOMImplementation.zig"),
@import("../webapi/DOMTreeWalker.zig"),
@import("../webapi/DOMNodeIterator.zig"),
@@ -1229,6 +1230,7 @@ const worker_common_apis = [_]type{
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/event/CloseEvent.zig"),
@import("../webapi/DOMException.zig"),
@import("../webapi/QuotaExceededError.zig"),
@import("../webapi/DOMRectReadOnly.zig"),
@import("../webapi/DOMRect.zig"),
@import("../webapi/DOMMatrixReadOnly.zig"),

View File

@@ -20,6 +20,7 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const SubtleCrypto = @import("SubtleCrypto.zig");
const QuotaExceededError = @import("QuotaExceededError.zig");
const Crypto = @This();
_subtle: SubtleCrypto = .{},
@@ -28,11 +29,17 @@ pub const init: Crypto = .{};
// We take a js.Value, because we want to return the same instance, not a new
// TypedArray
pub fn getRandomValues(_: *const Crypto, js_obj: js.Object) !js.Object {
pub fn getRandomValues(_: *const Crypto, js_obj: js.Object, exec: *const js.Execution) !js.Object {
const value = js_obj.toValue();
if (value.isFloat16Array() or value.isFloat32Array() or value.isFloat64Array() or (value.isArrayBufferView() and !value.isTypedArray())) {
// only integer TypedArrays are supported
return error.TypeMismatch;
}
var into = try js_obj.toZig(RandomValues);
const buf = into.asBuffer();
if (buf.len > 65_536) {
return error.QuotaExceeded;
return QuotaExceededError.throw(js_obj.local, exec);
}
lp.io.random(buf);
return js_obj;

View File

@@ -50,6 +50,7 @@ pub fn fromError(err: anyerror) ?DOMException {
error.InvalidModification => .{ ._code = .invalid_modification_error },
error.NamespaceError => .{ ._code = .namespace_error },
error.InvalidAccess => .{ ._code = .invalid_access_error },
error.TypeMismatch => .{ ._code = .type_mismatch_error },
error.SecurityError => .{ ._code = .security_error },
error.NetworkError => .{ ._code = .network_error },
error.AbortError => .{ ._code = .abort_error },
@@ -97,6 +98,7 @@ pub fn getName(self: *const DOMException) []const u8 {
.invalid_modification_error => "InvalidModificationError",
.namespace_error => "NamespaceError",
.invalid_access_error => "InvalidAccessError",
.type_mismatch_error => "TypeMismatchError",
.security_error => "SecurityError",
.network_error => "NetworkError",
.abort_error => "AbortError",
@@ -133,6 +135,7 @@ pub fn getMessage(self: *const DOMException) []const u8 {
.invalid_modification_error => "The object can not be modified in this way",
.namespace_error => "The operation is not allowed by Namespaces in XML",
.invalid_access_error => "The object does not support the operation or argument",
.type_mismatch_error => "The type of an object was incompatible with the expected type of the parameter associated to the object",
.security_error => "The operation is insecure",
.network_error => "A network error occurred",
.abort_error => "The operation was aborted",
@@ -178,6 +181,7 @@ const Code = enum(u8) {
invalid_modification_error = 13,
namespace_error = 14,
invalid_access_error = 15,
type_mismatch_error = 17,
security_error = 18,
network_error = 19,
abort_error = 20,
@@ -216,6 +220,7 @@ const Code = enum(u8) {
.{ "InvalidModificationError", .invalid_modification_error },
.{ "NamespaceError", .namespace_error },
.{ "InvalidAccessError", .invalid_access_error },
.{ "TypeMismatchError", .type_mismatch_error },
.{ "SecurityError", .security_error },
.{ "NetworkError", .network_error },
.{ "AbortError", .abort_error },

View File

@@ -0,0 +1,88 @@
// 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/>.
const js = @import("../js/js.zig");
const DOMException = @import("DOMException.zig");
const Execution = js.Execution;
const QuotaExceededError = @This();
pub const Proto = DOMException;
_proto: *DOMException,
_quota: ?f64 = null,
_requested: ?f64 = null,
const Options = struct {
quota: ?f64 = null,
requested: ?f64 = null,
};
pub fn init(message_: ?[]const u8, options_: ?Options, exec: *const Execution) !*QuotaExceededError {
const opts = options_ orelse Options{};
if (opts.quota) |quota| {
if (quota < 0) {
return error.RangeError;
}
}
if (opts.requested) |requested| {
if (requested < 0) {
return error.RangeError;
}
}
if (opts.quota != null and opts.requested != null and opts.requested.? < opts.quota.?) {
return error.RangeError;
}
const message = if (message_) |m| try exec.dupeString(m) else null;
return exec._factory.chained(.{
DOMException.init(message, "QuotaExceededError"),
QuotaExceededError{ ._proto = undefined, ._quota = opts.quota, ._requested = opts.requested },
});
}
pub fn throw(local: *const js.Local, exec: *const Execution) error{ TryCatchRethrow, OutOfMemory } {
const self = init(null, null, exec) catch return error.OutOfMemory;
const js_val = local.zigValueToJs(self, .{}) catch return error.OutOfMemory;
_ = local.isolate.throwException(js_val.handle);
return error.TryCatchRethrow;
}
pub fn getQuota(self: *const QuotaExceededError) ?f64 {
return self._quota;
}
pub fn getRequested(self: *const QuotaExceededError) ?f64 {
return self._requested;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(QuotaExceededError);
pub const Meta = struct {
pub const name = "QuotaExceededError";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(QuotaExceededError.init, .{});
pub const quota = bridge.accessor(QuotaExceededError.getQuota, null, .{});
pub const requested = bridge.accessor(QuotaExceededError.getRequested, null, .{});
};

View File

@@ -524,10 +524,10 @@ pub fn verify(
/// or an object (`{name: "SHA-256"}`). The object variant must come first — a
/// `[]const u8` coerces *any* JS value to a string, so it has to be the fallback.
const DigestInput = union(enum) {
obj: struct { name: []const u8 },
obj: struct { name: ?[]const u8 = null },
str: []const u8,
fn name(self: DigestInput) []const u8 {
fn name(self: DigestInput) ?[]const u8 {
return switch (self) {
.obj => |o| o.name,
.str => |s| s,
@@ -539,7 +539,9 @@ const DigestInput = union(enum) {
pub fn digest(_: *const SubtleCrypto, algo: DigestInput, data: js.TypedArray(u8), exec: *const Execution) !js.Promise {
const local = exec.js.local.?;
const algo_name = algo.name();
const algo_name = algo.name() orelse {
return local.rejectPromise(.{ .type_error = "required member name is undefined" });
};
if (algo_name.len > 10) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}

View File

@@ -37,6 +37,11 @@ pub fn init(
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
// The union probe match to get here is pretty simple, so we can end up here
// for an unknown/invalid algo.
if (!std.ascii.eqlIgnoreCase(params.name, "HMAC")) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
const hash_name = switch (params.hash) {
.string => |str| str,
.object => |obj| obj.name,

View File

@@ -26,12 +26,13 @@ const CryptoKey = @import("../CryptoKey.zig");
pub const Init = union(enum) {
/// For RSASSA-PKCS1-v1_5, RSA-PSS, or RSA-OAEP: pass an RsaHashedKeyGenParams object.
rsa_hashed_key_gen: RsaHashedKeyGen,
/// Must be before `hmac_key_gen`, since we need to ignore this param even
/// if it has a 'hash' property, and if it's after, we'll match that instead.
ec_key_gen: EcKeyGen,
/// For HMAC: pass an HmacKeyGenParams object.
hmac_key_gen: HmacKeyGen,
/// For AES variants: pass an AesKeyGenParams object.
aes_key_gen: AesKeyGen,
/// For ECDSA / ECDH: pass an EcKeyGenParams object.
ec_key_gen: EcKeyGen,
/// don't use []const u8 here, we don't want non-strings coerced. Let those
/// fall to the invalid case