From 57186e58277b7b0235d5e439356c3e2ab811d3e0 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 21 Mar 2026 07:36:10 -0700 Subject: [PATCH 1/7] quic: fix a handful of bugs and missing functionality Signed-off-by: James M Snell Assisted-by: Opencode/Opus 4.6 PR-URL: https://github.com/nodejs/node/pull/62387 Reviewed-By: Stephen Belanger --- lib/internal/blob.js | 113 ++++---- lib/internal/quic/quic.js | 12 + lib/internal/quic/state.js | 9 - src/node_blob.cc | 17 ++ src/node_blob.h | 3 + src/quic/application.cc | 25 +- src/quic/application.h | 7 +- src/quic/bindingdata.h | 3 + src/quic/cid.cc | 8 +- src/quic/data.cc | 2 +- src/quic/defs.h | 2 +- src/quic/endpoint.cc | 73 +++-- src/quic/endpoint.h | 3 - src/quic/http3.cc | 5 +- src/quic/logstream.cc | 14 +- src/quic/preferredaddress.cc | 17 +- src/quic/preferredaddress.h | 1 - src/quic/quic.cc | 7 + src/quic/session.cc | 93 +++++-- src/quic/session.h | 7 + src/quic/sessionticket.cc | 11 +- src/quic/streams.cc | 261 ++++++++++++++---- src/quic/streams.h | 19 +- src/quic/tlscontext.cc | 99 ++++--- src/quic/tlscontext.h | 19 ++ src/quic/tokens.cc | 24 +- src/quic/tokens.h | 14 +- src/quic/transportparams.h | 3 +- test/cctest/test_quic_preferredaddress.cc | 6 +- ...est-quic-internal-endpoint-stats-state.mjs | 1 - .../test-quic-internal-setcallbacks.mjs | 1 + 31 files changed, 597 insertions(+), 282 deletions(-) diff --git a/lib/internal/blob.js b/lib/internal/blob.js index e42773a8d03dcc..e1b1dceabd629d 100644 --- a/lib/internal/blob.js +++ b/lib/internal/blob.js @@ -439,63 +439,76 @@ function createBlobReaderStream(reader) { // There really should only be one read at a time so using an // array here is purely defensive. this.pendingPulls = []; + // Register a wakeup callback that the C++ side can invoke + // when new data is available after a STATUS_BLOCK. + reader.setWakeup(() => { + if (this.pendingPulls.length > 0) { + this.readNext(c); + } + }); }, pull(c) { const { promise, resolve, reject } = PromiseWithResolvers(); this.pendingPulls.push({ resolve, reject }); - const readNext = () => { - reader.pull((status, buffer) => { - // If pendingPulls is empty here, the stream had to have - // been canceled, and we don't really care about the result. - // We can simply exit. - if (this.pendingPulls.length === 0) { - return; - } - if (status === 0) { - // EOS - c.close(); - // This is to signal the end for byob readers - // see https://streams.spec.whatwg.org/#example-rbs-pull - c.byobRequest?.respond(0); - const pending = this.pendingPulls.shift(); - pending.resolve(); - return; - } else if (status < 0) { - // The read could fail for many different reasons when reading - // from a non-memory resident blob part (e.g. file-backed blob). - // The error details the system error code. - const error = lazyDOMException('The blob could not be read', 'NotReadableError'); - const pending = this.pendingPulls.shift(); - c.error(error); - pending.reject(error); + this.readNext(c); + return promise; + }, + readNext(c) { + reader.pull((status, buffer) => { + // If pendingPulls is empty here, the stream had to have + // been canceled, and we don't really care about the result. + // We can simply exit. + if (this.pendingPulls.length === 0) { + return; + } + if (status === 0) { + // EOS + c.close(); + // This is to signal the end for byob readers + // see https://streams.spec.whatwg.org/#example-rbs-pull + c.byobRequest?.respond(0); + const pending = this.pendingPulls.shift(); + pending.resolve(); + return; + } else if (status < 0) { + // The read could fail for many different reasons when reading + // from a non-memory resident blob part (e.g. file-backed blob). + // The error details the system error code. + const error = + lazyDOMException('The blob could not be read', + 'NotReadableError'); + const pending = this.pendingPulls.shift(); + c.error(error); + pending.reject(error); + return; + } else if (status === 2) { + // STATUS_BLOCK: No data available yet. The wakeup callback + // registered in start() will re-invoke readNext when data + // arrives. + return; + } + // ReadableByteStreamController.enqueue errors if we submit a + // 0-length buffer. We need to check for that here. + if (buffer !== undefined && buffer.byteLength !== 0) { + c.enqueue(new Uint8Array(buffer)); + } + // We keep reading until we either reach EOS, some error, or + // we hit the flow rate of the stream (c.desiredSize). + // We use setImmediate here because we have to allow the event + // loop to turn in order to process any pending i/o. Using + // queueMicrotask won't allow the event loop to turn. + setImmediate(() => { + if (c.desiredSize < 0) { + // A manual backpressure check. + if (this.pendingPulls.length !== 0) { + const pending = this.pendingPulls.shift(); + pending.resolve(); + } return; } - // ReadableByteStreamController.enqueue errors if we submit a 0-length - // buffer. We need to check for that here. - if (buffer !== undefined && buffer.byteLength !== 0) { - c.enqueue(new Uint8Array(buffer)); - } - // We keep reading until we either reach EOS, some error, or we - // hit the flow rate of the stream (c.desiredSize). - // We use set immediate here because we have to allow the event - // loop to turn in order to process any pending i/o. Using - // queueMicrotask won't allow the event loop to turn. - setImmediate(() => { - if (c.desiredSize < 0) { - // A manual backpressure check. - if (this.pendingPulls.length !== 0) { - // A case of waiting pull finished (= not yet canceled) - const pending = this.pendingPulls.shift(); - pending.resolve(); - } - return; - } - readNext(); - }); + this.readNext(c); }); - }; - readNext(); - return promise; + }); }, cancel(reason) { // Reject any currently pending pulls here. diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 9d394c9cebbf0e..53dfc9405015b0 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -477,6 +477,18 @@ setCallbacks({ this[kOwner][kSessionTicket](ticket); }, + /** + * Called when the client receives a NEW_TOKEN frame from the server. + * The token can be used for future connections to the same server + * address to skip address validation. + * @param {Buffer} token The opaque token data + * @param {SocketAddress} address The remote server address + */ + onSessionNewToken(token, address) { + debug('session new token callback', this[kOwner]); + // TODO(@jasnell): Emit to JS for storage and future reconnection use + }, + /** * Called when the session receives a session version negotiation request * @param {*} version diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 052749945a21df..2c768efa881658 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -77,7 +77,6 @@ const { IDX_STATE_STREAM_FIN_RECEIVED, IDX_STATE_STREAM_READ_ENDED, IDX_STATE_STREAM_WRITE_ENDED, - IDX_STATE_STREAM_PAUSED, IDX_STATE_STREAM_RESET, IDX_STATE_STREAM_HAS_OUTBOUND, IDX_STATE_STREAM_HAS_READER, @@ -113,7 +112,6 @@ assert(IDX_STATE_STREAM_FIN_SENT !== undefined); assert(IDX_STATE_STREAM_FIN_RECEIVED !== undefined); assert(IDX_STATE_STREAM_READ_ENDED !== undefined); assert(IDX_STATE_STREAM_WRITE_ENDED !== undefined); -assert(IDX_STATE_STREAM_PAUSED !== undefined); assert(IDX_STATE_STREAM_RESET !== undefined); assert(IDX_STATE_STREAM_HAS_OUTBOUND !== undefined); assert(IDX_STATE_STREAM_HAS_READER !== undefined); @@ -475,11 +473,6 @@ class QuicStreamState { return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WRITE_ENDED); } - /** @type {boolean} */ - get paused() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PAUSED); - } /** @type {boolean} */ get reset() { @@ -561,7 +554,6 @@ class QuicStreamState { finReceived: this.finReceived, readEnded: this.readEnded, writeEnded: this.writeEnded, - paused: this.paused, reset: this.reset, hasOutbound: this.hasOutbound, hasReader: this.hasReader, @@ -590,7 +582,6 @@ class QuicStreamState { finReceived: this.finReceived, readEnded: this.readEnded, writeEnded: this.writeEnded, - paused: this.paused, reset: this.reset, hasOutbound: this.hasOutbound, hasReader: this.hasReader, diff --git a/src/node_blob.cc b/src/node_blob.cc index 6371aad07beb85..fb85d5472e0248 100644 --- a/src/node_blob.cc +++ b/src/node_blob.cc @@ -320,6 +320,7 @@ Local Blob::Reader::GetConstructorTemplate(Environment* env) { Blob::Reader::kInternalFieldCount); tmpl->SetClassName(FIXED_ONE_BYTE_STRING(env->isolate(), "BlobReader")); SetProtoMethod(env->isolate(), tmpl, "pull", Pull); + SetProtoMethod(env->isolate(), tmpl, "setWakeup", SetWakeup); env->set_blob_reader_constructor_template(tmpl); } return tmpl; @@ -410,6 +411,21 @@ void Blob::Reader::Pull(const FunctionCallbackInfo& args) { std::move(next), node::bob::OPTIONS_END, nullptr, 0)); } +void Blob::Reader::SetWakeup( + const FunctionCallbackInfo& args) { + Blob::Reader* reader; + ASSIGN_OR_RETURN_UNWRAP(&reader, args.This()); + CHECK(args[0]->IsFunction()); + reader->wakeup_.Reset(args.GetIsolate(), args[0].As()); +} + +void Blob::Reader::NotifyPull() { + if (wakeup_.IsEmpty() || !env()->can_call_into_js()) return; + HandleScope handle_scope(env()->isolate()); + Local fn = wakeup_.Get(env()->isolate()); + MakeCallback(fn, 0, nullptr); +} + BaseObjectPtr Blob::BlobTransferData::Deserialize( Environment* env, @@ -590,6 +606,7 @@ void Blob::RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Blob::GetDataObject); registry->Register(Blob::RevokeObjectURL); registry->Register(Blob::Reader::Pull); + registry->Register(Blob::Reader::SetWakeup); registry->Register(Concat); registry->Register(BlobFromFilePath); } diff --git a/src/node_blob.h b/src/node_blob.h index c601015d9af47b..88a56c7ec9a453 100644 --- a/src/node_blob.h +++ b/src/node_blob.h @@ -82,6 +82,8 @@ class Blob : public BaseObject { static BaseObjectPtr Create(Environment* env, BaseObjectPtr blob); static void Pull(const v8::FunctionCallbackInfo& args); + static void SetWakeup(const v8::FunctionCallbackInfo& args); + void NotifyPull(); explicit Reader(Environment* env, v8::Local obj, @@ -95,6 +97,7 @@ class Blob : public BaseObject { std::shared_ptr inner_; BaseObjectPtr strong_ptr_; bool eos_ = false; + v8::Global wakeup_; }; BaseObject::TransferMode GetTransferMode() const override; diff --git a/src/quic/application.cc b/src/quic/application.cc index 36f11fb9131464..d4daeccc14fd1c 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -452,6 +452,7 @@ ssize_t Session::Application::WriteVStream(PathStorage* path, if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; return ngtcp2_conn_writev_stream(*session_, &path->path, + // TODO(@jasnell): ECN blocked on libuv nullptr, dest, max_packet_size, @@ -511,10 +512,13 @@ class DefaultApplication final : public Session::Application { stream_data->count = 0; stream_data->fin = 0; stream_data->stream.reset(); - stream_data->remaining = 0; Debug(&session(), "Default application getting stream data"); DCHECK_NOT_NULL(stream_data); // If the queue is empty, there aren't any streams with data yet + + // If the connection-level flow control window is exhausted, + // there is no point in pulling stream data. + if (!session().max_data_left()) return 0; if (stream_queue_.IsEmpty()) return 0; const auto get_length = [](auto vec, size_t count) { @@ -554,9 +558,7 @@ class DefaultApplication final : public Session::Application { if (count > 0) { stream->Schedule(&stream_queue_); - stream_data->remaining = get_length(data, count); } else { - stream_data->remaining = 0; } // Not calling done here because we defer committing @@ -581,15 +583,6 @@ class DefaultApplication final : public Session::Application { void ResumeStream(int64_t id) override { ScheduleStream(id); } - bool ShouldSetFin(const StreamData& stream_data) override { - auto const is_empty = [](const ngtcp2_vec* vec, size_t cnt) { - size_t i = 0; - for (size_t n = 0; n < cnt; n++) i += vec[n].len; - return i > 0; - }; - - return stream_data.stream && is_empty(stream_data, stream_data.count); - } void BlockStream(int64_t id) override { if (auto stream = session().FindStream(id)) [[likely]] { @@ -598,10 +591,9 @@ class DefaultApplication final : public Session::Application { } bool StreamCommit(StreamData* stream_data, size_t datalen) override { - if (datalen == 0) return true; DCHECK_NOT_NULL(stream_data); CHECK(stream_data->stream); - stream_data->stream->Commit(datalen); + stream_data->stream->Commit(datalen, stream_data->fin); return true; } @@ -616,11 +608,6 @@ class DefaultApplication final : public Session::Application { } } - void UnscheduleStream(int64_t id) { - if (auto stream = session().FindStream(id)) [[likely]] { - stream->Unschedule(); - } - } Stream::Queue stream_queue_; }; diff --git a/src/quic/application.h b/src/quic/application.h index b1f1131e1a7242..52e5f314518c60 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -120,7 +120,6 @@ class Session::Application : public MemoryRetainer { virtual int GetStreamData(StreamData* data) = 0; virtual bool StreamCommit(StreamData* data, size_t datalen) = 0; - virtual bool ShouldSetFin(const StreamData& data) = 0; inline Environment* env() const { return session().env(); } inline Session& session() { @@ -148,7 +147,6 @@ class Session::Application : public MemoryRetainer { struct Session::Application::StreamData final { // The actual number of vectors in the struct, up to kMaxVectorCount. size_t count = 0; - size_t remaining = 0; // The stream identifier. If this is a negative value then no stream is // identified. int64_t id = -1; @@ -156,6 +154,11 @@ struct Session::Application::StreamData final { ngtcp2_vec data[kMaxVectorCount]{}; BaseObjectPtr stream; + static_assert(sizeof(ngtcp2_vec) == sizeof(nghttp3_vec) && + alignof(ngtcp2_vec) == alignof(nghttp3_vec) && + offsetof(ngtcp2_vec, base) == offsetof(nghttp3_vec, base) && + offsetof(ngtcp2_vec, len) == offsetof(nghttp3_vec, len), + "ngtcp2_vec and nghttp3_vec must have identical layout"); inline operator nghttp3_vec*() { return reinterpret_cast(data); } diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 1b29a54a8c1199..9e778eee408ec3 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -43,6 +43,7 @@ class Packet; V(session_datagram_status, SessionDatagramStatus) \ V(session_handshake, SessionHandshake) \ V(session_new, SessionNew) \ + V(session_new_token, SessionNewToken) \ V(session_path_validation, SessionPathValidation) \ V(session_ticket, SessionTicket) \ V(session_version_negotiation, SessionVersionNegotiation) \ @@ -70,6 +71,7 @@ class Packet; V(cubic, "cubic") \ V(disable_stateless_reset, "disableStatelessReset") \ V(enable_connect_protocol, "enableConnectProtocol") \ + V(enable_early_data, "enableEarlyData") \ V(enable_datagrams, "enableDatagrams") \ V(enable_tls_trace, "tlsTrace") \ V(endpoint, "Endpoint") \ @@ -121,6 +123,7 @@ class Packet; V(stream, "Stream") \ V(success, "success") \ V(tls_options, "tls") \ + V(token, "token") \ V(token_expiration, "tokenExpiration") \ V(token_secret, "tokenSecret") \ V(transport_params, "transportParams") \ diff --git a/src/quic/cid.cc b/src/quic/cid.cc index 7255bf2f39adf3..16db80485f108b 100644 --- a/src/quic/cid.cc +++ b/src/quic/cid.cc @@ -85,10 +85,14 @@ const CID CID::kInvalid{}; // CID::Hash size_t CID::Hash::operator()(const CID& cid) const { + // Uses the Boost hash_combine strategy: XOR each byte with the golden + // ratio constant 0x9e3779b9 (derived from the fractional part of the + // golden ratio, (sqrt(5)-1)/2 * 2^32) plus bit-shifted accumulator + // state. This provides good avalanche properties for short byte + // sequences like connection IDs (1-20 bytes). size_t hash = 0; for (size_t n = 0; n < cid.length(); n++) { - hash ^= std::hash{}(cid.ptr_->data[n] + 0x9e3779b9 + (hash << 6) + - (hash >> 2)); + hash ^= cid.ptr_->data[n] + 0x9e3779b9 + (hash << 6) + (hash >> 2); } return hash; } diff --git a/src/quic/data.cc b/src/quic/data.cc index 99a2b5af3c4f6a..f43ae4ce6edbc4 100644 --- a/src/quic/data.cc +++ b/src/quic/data.cc @@ -28,7 +28,7 @@ using v8::Undefined; using v8::Value; namespace quic { -int DebugIndentScope::indent_ = 0; +thread_local int DebugIndentScope::indent_ = 0; Path::Path(const SocketAddress& local, const SocketAddress& remote) { ngtcp2_addr_init(&this->local, local.data(), local.length()); diff --git a/src/quic/defs.h b/src/quic/defs.h index 74505d8e401f8d..b26ca5f9a4f12e 100644 --- a/src/quic/defs.h +++ b/src/quic/defs.h @@ -338,7 +338,7 @@ class DebugIndentScope final { } private: - static int indent_; + static thread_local int indent_; }; } // namespace node::quic diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 2601a48def34d2..bd99856ca67d1a 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -319,23 +319,33 @@ class Endpoint::UDP::Impl final : public HandleWrap { const uv_buf_t* buf, const sockaddr* addr, unsigned int flags) { - // Nothing to do in these cases. Specifically, if the nread - // is zero or we've received a partial packet, we're just - // going to ignore it. - if (nread == 0 || flags & UV_UDP_PARTIAL) return; - auto impl = From(handle); DCHECK_NOT_NULL(impl); DCHECK_NOT_NULL(impl->endpoint_); + auto release_buf = [&]() { + if (buf->base != nullptr) + impl->env()->release_managed_buffer(*buf); + }; + + // Nothing to do in these cases. Specifically, if the nread + // is zero or we have received a partial packet, we are just + // going to ignore it. + if (nread == 0 || flags & UV_UDP_PARTIAL) { + release_buf(); + return; + } + if (nread < 0) { + release_buf(); impl->endpoint_->Destroy(CloseContext::RECEIVE_FAILURE, static_cast(nread)); return; } - impl->endpoint_->Receive(uv_buf_init(buf->base, static_cast(nread)), - SocketAddress(addr)); + impl->endpoint_->Receive( + uv_buf_init(buf->base, static_cast(nread)), + SocketAddress(addr)); } uv_udp_t handle_; @@ -739,6 +749,7 @@ void Endpoint::Send(const BaseObjectPtr& packet) { Debug(this, "Sending packet failed with error %d", err); packet->Done(err); Destroy(CloseContext::SEND_FAILURE, err); + return; } STAT_INCREMENT_N(Stats, bytes_sent, packet->length()); STAT_INCREMENT(Stats, packets_sent); @@ -988,7 +999,6 @@ void Endpoint::Destroy(CloseContext context, int status) { this, "Destroying endpoint due to \"%s\" with status %d", ctx, status); } - STAT_RECORD_TIMESTAMP(Stats, destroyed_at); state_->listening = 0; @@ -1007,6 +1017,7 @@ void Endpoint::Destroy(CloseContext context, int status) { DCHECK(sessions_.empty()); token_map_.clear(); dcid_to_scid_.clear(); + server_state_.reset(); udp_.Close(); state_->closing = 0; @@ -1345,29 +1356,33 @@ void Endpoint::Receive(const uv_buf_t& buf, } break; case NGTCP2_PKT_0RTT: + // 0-RTT packets are inherently replayable and could be sent + // from a spoofed source address to trigger amplification. + // When address validation is enabled, we send a Retry to + // force the client to prove it can receive at its claimed + // address. This adds a round trip but prevents amplification + // attacks. When address validation is disabled (e.g., on + // trusted networks), we skip the Retry and allow 0-RTT to + // proceed without additional validation. + if (options_.validate_address) { + Debug(this, + "Sending retry to %s due to 0RTT packet", + remote_address); + SendRetry(PathDescriptor{ + version, + dcid, + scid, + local_address, + remote_address, + }); + STAT_INCREMENT(Stats, packets_received); + return; + } Debug(this, - "Sending retry to %s due to initial 0RTT packet", + "Accepting 0RTT packet from %s without " + "address validation", remote_address); - // If it's a 0RTT packet, we're always going to perform path - // validation no matter what. This is a bit unfortunate since - // ORTT is supposed to be, you know, 0RTT, but sending a retry - // forces a round trip... but if the remote address is not - // validated, there's a possibility that this 0RTT is forged - // or otherwise suspicious. Before we can do anything with it, - // we have to validate it. Keep in mind that this means the - // client needs to respond with a proper initial packet in - // order to proceed. - // TODO(@jasnell): Validate this further to ensure this is - // the correct behavior. - SendRetry(PathDescriptor{ - version, - dcid, - scid, - local_address, - remote_address, - }); - STAT_INCREMENT(Stats, packets_received); - return; + break; } } diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h index 4c218656227aef..7ed9fee81e46b6 100644 --- a/src/quic/endpoint.h +++ b/src/quic/endpoint.h @@ -311,7 +311,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // be prevented. void CloseGracefully(); - void Release(); void PacketDone(int status) override; @@ -346,7 +345,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // packets. // @param bool on - If true, mark the Endpoint as busy. JS_METHOD(MarkBusy); - static void FastMarkBusy(v8::Local receiver, bool on); // DoCloseGracefully is the signal that endpoint should close. Any packets // that are already in the queue or in flight will be allowed to finish, but @@ -360,7 +358,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // Ref() causes a listening Endpoint to keep the event loop active. JS_METHOD(Ref); - static void FastRef(v8::Local receiver, bool on); void Receive(const uv_buf_t& buf, const SocketAddress& from); diff --git a/src/quic/http3.cc b/src/quic/http3.cc index b3244fdad365be..d2da2a836eadde 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -498,12 +498,11 @@ class Http3ApplicationImpl final : public Session::Application { nghttp3_err_infer_quic_app_error_code(err))); return false; } + if (data->stream) + data->stream->Commit(datalen, data->fin); return true; } - bool ShouldSetFin(const StreamData& data) override { - return data.id > -1 && !is_control_stream(data.id) && data.fin == 1; - } SET_NO_MEMORY_INFO() SET_MEMORY_INFO_NAME(Http3ApplicationImpl) diff --git a/src/quic/logstream.cc b/src/quic/logstream.cc index 4705d75bdafac0..511b2a1ef46ebe 100644 --- a/src/quic/logstream.cc +++ b/src/quic/logstream.cc @@ -46,22 +46,22 @@ void LogStream::Emit(const uint8_t* data, size_t len, EmitOption option) { // If the len is greater than the size of the buffer returned by // EmitAlloc then EmitRead will be called multiple times. while (remaining != 0) { - uv_buf_t buf = EmitAlloc(len); - size_t len = std::min(remaining, buf.len); - memcpy(buf.base, data, len); - remaining -= len; - data += len; + uv_buf_t buf = EmitAlloc(remaining); + size_t chunk_len = std::min(remaining, buf.len); + memcpy(buf.base, data, chunk_len); + remaining -= chunk_len; + data += chunk_len; // If we are actively reading from the stream, we'll call emit // read immediately. Otherwise we buffer the chunk and will push // the chunks out the next time ReadStart() is called. if (reading_) { - EmitRead(len, buf); + EmitRead(chunk_len, buf); } else { // The total measures the total memory used so we always // increment but buf.len and not chunk len. ensure_space(buf.len); total_ += buf.len; - buffer_.push_back(Chunk{len, buf}); + buffer_.push_back(Chunk{chunk_len, buf}); } } diff --git a/src/quic/preferredaddress.cc b/src/quic/preferredaddress.cc index b45e4689a21c81..51dddd5ecd7f94 100644 --- a/src/quic/preferredaddress.cc +++ b/src/quic/preferredaddress.cc @@ -30,20 +30,15 @@ std::optional get_address_info( if constexpr (FAMILY == AF_INET) { if (!paddr.ipv4_present) return std::nullopt; address.port = paddr.ipv4.sin_port; - if (uv_inet_ntop( - FAMILY, &paddr.ipv4.sin_addr, address.host, sizeof(address.host)) == - 0) { - address.address = address.host; - } + if (uv_inet_ntop(FAMILY, &paddr.ipv4.sin_addr, + address.host, sizeof(address.host)) != 0) + return std::nullopt; } else { if (!paddr.ipv6_present) return std::nullopt; address.port = paddr.ipv6.sin6_port; - if (uv_inet_ntop(FAMILY, - &paddr.ipv6.sin6_addr, - address.host, - sizeof(address.host)) == 0) { - address.address = address.host; - } + if (uv_inet_ntop(FAMILY, &paddr.ipv6.sin6_addr, + address.host, sizeof(address.host)) != 0) + return std::nullopt; } return address; } diff --git a/src/quic/preferredaddress.h b/src/quic/preferredaddress.h index c121322a07bfbc..a7df606cf5696d 100644 --- a/src/quic/preferredaddress.h +++ b/src/quic/preferredaddress.h @@ -41,7 +41,6 @@ class PreferredAddress final { char host[NI_MAXHOST]; int family; uint16_t port; - std::string_view address; }; explicit PreferredAddress(ngtcp2_path* dest, diff --git a/src/quic/quic.cc b/src/quic/quic.cc index e36879e4e7d36b..d39215a06827d3 100644 --- a/src/quic/quic.cc +++ b/src/quic/quic.cc @@ -12,6 +12,8 @@ #include "endpoint.h" #include "node_external_reference.h" +#include +#include namespace node { using v8::Context; @@ -22,6 +24,10 @@ using v8::Value; namespace quic { +namespace { +std::once_flag crypto_init_flag; +} // namespace + void CreatePerIsolateProperties(IsolateData* isolate_data, Local target) { Endpoint::InitPerIsolate(isolate_data, target); @@ -34,6 +40,7 @@ void CreatePerContextProperties(Local target, Local context, void* priv) { Realm* realm = Realm::GetCurrent(context); + std::call_once(crypto_init_flag, ngtcp2_crypto_ossl_init); BindingData::InitPerContext(realm, target); Endpoint::InitPerContext(realm, target); Session::InitPerContext(realm, target); diff --git a/src/quic/session.cc b/src/quic/session.cc index 39ffad3e09faa8..351d5c61a417b8 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -345,7 +345,10 @@ Session::Config::Config(Environment* env, ngtcp2_settings_default(&settings); settings.initial_ts = uv_hrtime(); - // We currently do not support Path MTU Discovery. Once we do, unset this. + // TODO(@jasnell): Path MTU Discovery is disabled because libuv does not + // currently expose the IP_DONTFRAG / IP_MTU_DISCOVER socket options + // needed for PMTUD probes to work correctly. Revisit when libuv adds + // support or if we bypass libuv for the UDP socket options. settings.no_pmtud = 1; // Per the ngtcp2 documentation, when no_tx_udp_payload_size_shaping is set // to a non-zero value, it tells ngtcp2 not to limit the UDP payload size to @@ -372,6 +375,11 @@ Session::Config::Config(Environment* env, settings.max_window = options.max_window; settings.ack_thresh = options.unacknowledged_packet_threshold; settings.cc_algo = options.cc_algorithm; + + if (side == Side::CLIENT && options.token.has_value()) { + ngtcp2_vec vec = options.token.value(); + set_token(vec.base, vec.len, NGTCP2_TOKEN_TYPE_NEW_TOKEN); + } } Session::Config::Config(Environment* env, @@ -465,6 +473,17 @@ Maybe Session::Options::From(Environment* env, // TODO(@jasnell): Later we will also support setting the CID::Factory. // For now, we're just using the default random factory. + // Parse the optional NEW_TOKEN for address validation on reconnection. + Local token_val; + if (params->Get(env->context(), state.token_string()) + .ToLocal(&token_val) && + token_val->IsArrayBufferView()) { + Store token_store; + if (Store::From(token_val.As()).To(&token_store)) { + options.token = std::move(token_store); + } + } + return Just(options); } @@ -771,16 +790,16 @@ struct Session::Impl final : public MemoryRetainer { Session* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); - if (session->is_destroyed()) { + if (session->is_destroyed()) [[unlikely]] { return THROW_ERR_INVALID_STATE(env, "Session is destroyed"); } DCHECK(args[0]->IsUint32()); // GetDataQueueFromSource handles type validation. - std::shared_ptr data_source = - Stream::GetDataQueueFromSource(env, args[1]).ToChecked(); - if (data_source == nullptr) { + std::shared_ptr data_source; + if (!Stream::GetDataQueueFromSource(env, args[1]).To(&data_source) || + data_source == nullptr) [[unlikely]] { THROW_ERR_INVALID_ARG_VALUE(env, "Invalid data source"); } @@ -878,7 +897,9 @@ struct Session::Impl final : public MemoryRetainer { uint64_t max_streams, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) - // TODO(@jasnell): Do anything here? + Debug(session, + "Max remote bidi streams increased to %" PRIu64, + max_streams); return NGTCP2_SUCCESS; } @@ -886,7 +907,7 @@ struct Session::Impl final : public MemoryRetainer { uint64_t max_streams, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) - // TODO(@jasnell): Do anything here? + Debug(session, "Max remote uni streams increased to %" PRIu64, max_streams); return NGTCP2_SUCCESS; } @@ -993,7 +1014,8 @@ struct Session::Impl final : public MemoryRetainer { size_t tokenlen, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) - // We currently do nothing with this callback. + Debug(session, "Received NEW_TOKEN (%zu bytes)", tokenlen); + session->EmitNewToken(token, tokenlen); return NGTCP2_SUCCESS; } @@ -1155,8 +1177,9 @@ struct Session::Impl final : public MemoryRetainer { } static int on_early_data_rejected(ngtcp2_conn* conn, void* user_data) { - // TODO(@jasnell): Called when early data was rejected by server during the - // TLS handshake or client decided not to attempt early data. + auto session = Impl::From(conn, user_data); + if (session == nullptr) return NGTCP2_ERR_CALLBACK_FAILURE; + Debug(session, "Early data was rejected"); return NGTCP2_SUCCESS; } @@ -1165,7 +1188,8 @@ struct Session::Impl final : public MemoryRetainer { const ngtcp2_path* path, const ngtcp2_path* fallback_path, void* user_data) { - // TODO(@jasnell): Implement? + NGTCP2_CALLBACK_SCOPE(session) + Debug(session, "Path validation started"); return NGTCP2_SUCCESS; } @@ -1607,12 +1631,15 @@ bool Session::Receive(Store&& store, // session is not destroyed before we try doing anything with it // (like updating stats, sending pending data, etc). int err = ngtcp2_conn_read_pkt( - *this, &path, nullptr, vec.base, vec.len, uv_hrtime()); + *this, &path, + // TODO(@jasnell): ECN pkt_info blocked on libuv + nullptr, + vec.base, vec.len, uv_hrtime()); switch (err) { case 0: { Debug(this, "Session successfully received %zu-byte packet", vec.len); - if (!is_destroyed()) [[unlikely]] { + if (!is_destroyed()) [[likely]] { auto& stats_ = impl_->stats_; STAT_INCREMENT_N(Stats, bytes_received, vec.len); } @@ -2400,9 +2427,10 @@ bool Session::HandshakeCompleted() { STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at); SetStreamOpenAllowed(); - // TODO(@jasnel): Not yet supporting early data... - // if (!tls_session().early_data_was_accepted()) - // ngtcp2_conn_tls_early_data_rejected(*this); + // If early data was attempted but rejected by the server, + // tell ngtcp2 so it can retransmit the data as 1-RTT. + if (!is_server() && !tls_session().early_data_was_accepted()) + ngtcp2_conn_tls_early_data_rejected(*this); // When in a server session, handshake completed == handshake confirmed. if (is_server()) { @@ -2448,9 +2476,9 @@ void Session::SelectPreferredAddress(PreferredAddress* preferredAddress) { Debug(this, "Selecting preferred address for AF_INET"); auto ipv4 = preferredAddress->ipv4(); if (ipv4.has_value()) { - if (ipv4->address.empty() || ipv4->port == 0) return; + if (ipv4->host[0] == '\0' || ipv4->port == 0) return; CHECK(SocketAddress::New(AF_INET, - std::string(ipv4->address).c_str(), + ipv4->host, ipv4->port, &impl_->remote_address_)); preferredAddress->Use(ipv4.value()); @@ -2461,9 +2489,9 @@ void Session::SelectPreferredAddress(PreferredAddress* preferredAddress) { Debug(this, "Selecting preferred address for AF_INET6"); auto ipv6 = preferredAddress->ipv6(); if (ipv6.has_value()) { - if (ipv6->address.empty() || ipv6->port == 0) return; - CHECK(SocketAddress::New(AF_INET, - std::string(ipv6->address).c_str(), + if (ipv6->host[0] == '\0' || ipv6->port == 0) return; + CHECK(SocketAddress::New(AF_INET6, + ipv6->host, ipv6->port, &impl_->remote_address_)); preferredAddress->Use(ipv6.value()); @@ -2528,8 +2556,8 @@ void Session::ProcessPendingUniStreams() { } case NGTCP2_ERR_STREAM_ID_BLOCKED: { // This case really should not happen since we've checked the number - // of bidi streams left above. However, if it does happen we'll treat - // it the same as if the get_streams_bidi_left call returned zero. + // of uni streams left above. However, if it does happen we'll treat + // it the same as if the get_streams_uni_left call returned zero. return; } default: { @@ -2732,6 +2760,25 @@ void Session::EmitSessionTicket(Store&& ticket) { } } +void Session::EmitNewToken(const uint8_t* token, size_t len) { + DCHECK(!is_destroyed()); + if (!env()->can_call_into_js()) return; + + CallbackScope cb_scope(this); + + Local argv[2]; + auto buf = Buffer::Copy( + env(), reinterpret_cast(token), len); + if (!buf.ToLocal(&argv[0])) return; + argv[1] = SocketAddressBase::Create( + env(), + std::make_shared(remote_address()))->object(); + MakeCallback( + BindingData::Get(env()).session_new_token_callback(), + arraysize(argv), + argv); +} + void Session::EmitStream(const BaseObjectWeakPtr& stream) { DCHECK(!is_destroyed()); diff --git a/src/quic/session.h b/src/quic/session.h index ddaddb8d18a7a7..1b7cb49d9e373e 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -58,6 +58,7 @@ class Endpoint; // object itself is closed/destroyed by user code. class Session final : public AsyncWrap, private SessionTicket::AppData::Source { public: + SessionTicket::AppData::Source& ticket_app_data_source() { return *this; } // For simplicity, we use the same Application::Options struct for all // Application types. This may change in the future. Not all of the options // are going to be relevant for all Application types. @@ -176,6 +177,11 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // is the better of the two for our needs. ngtcp2_cc_algo cc_algorithm = CC_ALGO_CUBIC; + // An optional NEW_TOKEN from a previous connection to the same + // server. When set, the token is included in the Initial packet + // to skip address validation. Client-side only. + std::optional token; + void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(Session::Options) SET_SELF_SIZE(Options) @@ -477,6 +483,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { const ValidatedPath& newPath, const std::optional& oldPath); void EmitSessionTicket(Store&& ticket); + void EmitNewToken(const uint8_t* token, size_t len); void EmitStream(const BaseObjectWeakPtr& stream); void EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, const uint32_t* sv, diff --git a/src/quic/sessionticket.cc b/src/quic/sessionticket.cc index 8956b44068e2ee..2fe44c1f56e48a 100644 --- a/src/quic/sessionticket.cc +++ b/src/quic/sessionticket.cc @@ -2,6 +2,9 @@ #include "guard.h" #ifndef OPENSSL_NO_QUIC #include "sessionticket.h" +#include "tlscontext.h" +#include "session.h" +#include #include #include #include @@ -25,12 +28,8 @@ namespace quic { namespace { SessionTicket::AppData::Source* GetAppDataSource(SSL* ssl) { - ngtcp2_crypto_conn_ref* ref = - static_cast(SSL_get_app_data(ssl)); - if (ref != nullptr && ref->user_data != nullptr) { - return static_cast(ref->user_data); - } - return nullptr; + auto& tls_session = TLSSession::From(ssl); + return &tls_session.session().ticket_app_data_source(); } } // namespace diff --git a/src/quic/streams.cc b/src/quic/streams.cc index f84bf4252e4877..4b1536629580de 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -30,6 +30,7 @@ using v8::Nothing; using v8::Object; using v8::ObjectTemplate; using v8::SharedArrayBuffer; +using v8::Uint8Array; using v8::Value; namespace quic { @@ -41,7 +42,6 @@ namespace quic { V(FIN_RECEIVED, fin_received, uint8_t) \ V(READ_ENDED, read_ended, uint8_t) \ V(WRITE_ENDED, write_ended, uint8_t) \ - V(PAUSED, paused, uint8_t) \ V(RESET, reset, uint8_t) \ V(HAS_OUTBOUND, has_outbound, uint8_t) \ V(HAS_READER, has_reader, uint8_t) \ @@ -83,7 +83,10 @@ namespace quic { V(ResetStream, resetStream, false) \ V(SetPriority, setPriority, false) \ V(GetPriority, getPriority, true) \ - V(GetReader, getReader, false) + V(GetReader, getReader, false) \ + V(InitStreamingSource, initStreamingSource, false) \ + V(Write, write, false) \ + V(EndWrite, endWrite, false) // ============================================================================ @@ -140,6 +143,38 @@ STAT_STRUCT(Stream, STREAM) // ============================================================================ +namespace { +// Creates an in-memory DataQueue entry from an ArrayBuffer by either +// detaching it (zero-copy) or copying its contents if detach is not +// possible (e.g., SharedArrayBuffer-backed or non-detachable). +// Returns nullptr on failure (error already thrown if allocation failed). +std::unique_ptr CreateEntryFromBuffer( + Environment* env, + Local buffer, + size_t offset, + size_t length) { + if (length == 0) return nullptr; + std::shared_ptr backing; + if (buffer->IsDetachable()) { + backing = buffer->GetBackingStore(); + if (buffer->Detach(Local()).IsNothing()) { + backing.reset(); + } + } + if (!backing) { + // Buffer is not detachable or detach failed. Copy the data. + JS_TRY_ALLOCATE_BACKING_OR_RETURN(env, copy, length, nullptr); + memcpy(copy->Data(), + static_cast(buffer->Data()) + offset, + length); + offset = 0; + backing = std::move(copy); + } + return DataQueue::CreateInMemoryEntryFromBackingStore( + std::move(backing), offset, length); +} +} // namespace + Maybe> Stream::GetDataQueueFromSource( Environment* env, Local value) { DCHECK_IMPLIES(!value->IsUndefined(), value->IsObject()); @@ -148,59 +183,41 @@ Maybe> Stream::GetDataQueueFromSource( // Return an empty DataQueue. return Just(std::shared_ptr()); } else if (value->IsArrayBuffer()) { - // DataQueue is created from an ArrayBuffer. auto buffer = value.As(); - // We require that the ArrayBuffer be detachable. This ensures that the - // underlying memory can be transferred to the DataQueue without risk - // of the memory being modified by JavaScript code while it is owned - // by the DataQueue. - if (!buffer->IsDetachable()) { - THROW_ERR_INVALID_ARG_TYPE(env, "Data source not detachable"); - return Nothing>(); - } - auto backing = buffer->GetBackingStore(); - uint64_t offset = 0; - uint64_t length = buffer->ByteLength(); - if (buffer->Detach(Local()).IsNothing()) { - THROW_ERR_INVALID_ARG_TYPE(env, "Data source not detachable"); - return Nothing>(); + auto length = buffer->ByteLength(); + if (length > 0) { + auto entry = CreateEntryFromBuffer(env, buffer, 0, length); + if (!entry) { + return Nothing>(); + } + entries.push_back(std::move(entry)); } - entries.push_back(DataQueue::CreateInMemoryEntryFromBackingStore( - std::move(backing), offset, length)); return Just(DataQueue::CreateIdempotent(std::move(entries))); } else if (value->IsSharedArrayBuffer()) { - // We aren't going to allow use of SharedArrayBuffer as a data source. - // The reason is that SharedArrayBuffer memory is possibly shared with - // other JavaScript code and we cannot detach it, making it impossible - // for us to guarantee that the memory will not be modified while it - // is owned by the DataQueue. - THROW_ERR_INVALID_ARG_TYPE(env, "SharedArrayBuffer is not allowed"); - return Nothing>(); + auto sab = value.As(); + auto length = sab->ByteLength(); + if (length > 0) { + // SharedArrayBuffer cannot be detached, so we always copy. Note that + // because of the nature of SAB, another thread can end up modifying + // the SAB while we're copying, which is racy but unavoidable. + JS_TRY_ALLOCATE_BACKING_OR_RETURN( + env, backing, length, Nothing>()); + memcpy(backing->Data(), sab->Data(), length); + entries.push_back(DataQueue::CreateInMemoryEntryFromBackingStore( + std::move(backing), 0, length)); + } + return Just(DataQueue::CreateIdempotent(std::move(entries))); } else if (value->IsArrayBufferView()) { auto view = value.As(); - auto buffer = view->Buffer(); - if (buffer->IsSharedArrayBuffer()) { - // We aren't going to allow use of SharedArrayBuffer as a data source. - // The reason is that SharedArrayBuffer memory is possibly shared with - // other JavaScript code and we cannot detach it, making it impossible - // for us to guarantee that the memory will not be modified while it - // is owned by the DataQueue. - THROW_ERR_INVALID_ARG_TYPE(env, "SharedArrayBuffer is not allowed"); - return Nothing>(); - } - if (!buffer->IsDetachable()) { - THROW_ERR_INVALID_ARG_TYPE(env, "Data source not detachable"); - return Nothing>(); - } - if (buffer->Detach(Local()).IsNothing()) { - THROW_ERR_INVALID_ARG_TYPE(env, "Data source not detachable"); - return Nothing>(); - } - auto backing = buffer->GetBackingStore(); auto offset = view->ByteOffset(); auto length = view->ByteLength(); - entries.push_back(DataQueue::CreateInMemoryEntryFromBackingStore( - std::move(backing), offset, length)); + if (length > 0) { + auto entry = CreateEntryFromBuffer(env, view->Buffer(), offset, length); + if (!entry) { + return Nothing>(); + } + entries.push_back(std::move(entry)); + } return Just(DataQueue::CreateIdempotent(std::move(entries))); } else if (Blob::HasInstance(env, value)) { Blob* blob; @@ -242,9 +259,15 @@ struct Stream::Impl { ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); if (args.Length() > 1) { CHECK(args[0]->IsBigInt()); - bool unused = false; - stream->Destroy(QuicError::ForApplication( - args[0].As()->Uint64Value(&unused))); + bool lossless = false; + uint64_t code = args[0].As()->Uint64Value(&lossless); + // If the code cannot be represented in 64 bits, it is too large to be + // a valid QUIC error code, error! + if (!lossless) { + THROW_ERR_INVALID_ARG_TYPE(stream->env(), "Error code is too large"); + return; + } + stream->Destroy(QuicError::ForApplication(code)); } else { stream->Destroy(); } @@ -375,17 +398,43 @@ struct Stream::Impl { THROW_ERR_INVALID_STATE(Environment::GetCurrent(args), "Unable to get a reader for the stream"); } + + JS_METHOD(InitStreamingSource) { + Stream* stream; + ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); + stream->InitStreaming(); + } + + JS_METHOD(Write) { + Stream* stream; + ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); + stream->WriteStreamData(args); + } + + JS_METHOD(EndWrite) { + Stream* stream; + ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); + stream->EndWriting(); + } }; // ============================================================================ class Stream::Outbound final : public MemoryRetainer { public: - Outbound(Stream* stream, std::shared_ptr queue) + explicit Outbound(Stream* stream, std::shared_ptr queue) : stream_(stream), queue_(std::move(queue)), reader_(queue_->get_reader()) {} + // Creates an Outbound in streaming mode with a non-idempotent DataQueue + // that can be appended to via AppendEntry(). + explicit Outbound(Stream* stream) + : stream_(stream), + queue_(DataQueue::Create()), + reader_(queue_->get_reader()), + streaming_(true) {} + void Acknowledge(size_t amount) { size_t remaining = std::min(amount, total_ - uncommitted_); while (remaining > 0 && head_ != nullptr) { @@ -457,6 +506,17 @@ class Stream::Outbound final : public MemoryRetainer { if (queue_) queue_->cap(); } + bool is_streaming() const { return streaming_; } + size_t total() const { return total_; } + + // Appends an entry to the underlying DataQueue. Only valid when + // the Outbound was created in streaming mode. + bool AppendEntry(std::unique_ptr entry) { + if (!streaming_ || !queue_) return false; + auto result = queue_->append(std::move(entry)); + return result.has_value() && result.value(); + } + int Pull(bob::Next next, int options, ngtcp2_vec* data, @@ -703,6 +763,9 @@ class Stream::Outbound final : public MemoryRetainer { bool errored_ = false; + // True when in streaming mode (non-idempotent queue, appendable). + bool streaming_ = false; + // Will be set to true if the reader_ ends up providing a pull result // asynchronously. bool next_pending_ = false; @@ -1024,7 +1087,9 @@ bool Stream::is_readable() const { BaseObjectPtr Stream::get_reader() { if (!is_readable() || state_->has_reader) return {}; state_->has_reader = 1; - return Blob::Reader::Create(env(), Blob::Create(env(), inbound_)); + auto reader = Blob::Reader::Create(env(), Blob::Create(env(), inbound_)); + reader_ = reader; + return reader; } void Stream::set_final_size(uint64_t final_size) { @@ -1043,11 +1108,86 @@ void Stream::set_outbound(std::shared_ptr source) { if (!is_pending()) session_->ResumeStream(id()); } +void Stream::InitStreaming() { + auto env = this->env(); + if (outbound_ != nullptr) { + return THROW_ERR_INVALID_STATE(env, + "Outbound data source is already initialized"); + } + if (!is_writable()) { + return THROW_ERR_INVALID_STATE(env, "Stream is not writable"); + } + Debug(this, "Initializing streaming outbound source"); + outbound_ = std::make_unique(this); + state_->has_outbound = 1; + if (!is_pending()) session_->ResumeStream(id()); +} + +void Stream::WriteStreamData(const v8::FunctionCallbackInfo& args) { + auto env = this->env(); + if (outbound_ == nullptr || !outbound_->is_streaming()) { + return THROW_ERR_INVALID_STATE(env, + "Streaming source is not initialized"); + } + + if (!is_writable()) { + return THROW_ERR_INVALID_STATE(env, "Stream is no longer writable"); + } + + auto append_view = [&](Local value) -> bool { + if (!value->IsUint8Array()) { + THROW_ERR_INVALID_ARG_TYPE(env, "Expected Uint8Array"); + return false; + } + auto view = value.As(); + auto length = view->ByteLength(); + if (length == 0) return true; + auto entry = CreateEntryFromBuffer( + env, view->Buffer(), view->ByteOffset(), length); + if (!entry) { + return false; + } + return outbound_->AppendEntry(std::move(entry)); + }; + + // There must always be exactly one argument to WriteStreamData. + CHECK_EQ(args.Length(), 1); + + // The args[0] must always be an Array of Uint8Arrays + CHECK(args[0]->IsArray()); + + auto array = args[0].As(); + for (uint32_t i = 0; i < array->Length(); i++) { + Local item; + if (!array->Get(env->context(), i).ToLocal(&item)) return; + if (!append_view(item)) return; + } + + if (!is_pending()) session_->ResumeStream(id()); + + args.GetReturnValue().Set(static_cast(outbound_->total())); +} + +void Stream::EndWriting() { + auto env = this->env(); + if (outbound_ == nullptr || !outbound_->is_streaming()) { + return THROW_ERR_INVALID_STATE(env, "Streaming source is not initialized"); + } + + if (!is_writable()) { + return THROW_ERR_INVALID_STATE(env, "Stream is no longer writable"); + } + Debug(this, "Ending streaming outbound source"); + EndWritable(); + if (!is_pending()) session_->ResumeStream(id()); +} + void Stream::EntryRead(size_t amount) { // Tells us that amount bytes we're reading from inbound_ // We use this as a signal to extend the flow control // window to receive more bytes. session().ExtendStreamOffset(id(), amount); + session().ExtendOffset(amount); } int Stream::DoPull(bob::Next next, @@ -1119,12 +1259,14 @@ void Stream::Acknowledge(size_t datalen) { // Consumes the given number of bytes in the buffer. outbound_->Acknowledge(datalen); + STAT_RECORD_TIMESTAMP(Stats, acked_at); } -void Stream::Commit(size_t datalen) { +void Stream::Commit(size_t datalen, bool fin) { Debug(this, "Committing %zu bytes", datalen); - STAT_RECORD_TIMESTAMP(Stats, acked_at); + STAT_INCREMENT_N(Stats, bytes_sent, datalen); if (outbound_) outbound_->Commit(datalen); + if (fin) state_->fin_sent = 1; } void Stream::EndWritable() { @@ -1142,6 +1284,8 @@ void Stream::EndReadable(std::optional maybe_final_size) { state_->read_ended = 1; set_final_size(maybe_final_size.value_or(STAT_GET(Stats, bytes_received))); inbound_->cap(STAT_GET(Stats, final_size)); + // Notify the JS reader so it can see EOS. + if (reader_) reader_->NotifyPull(); } void Stream::Destroy(QuicError error) { @@ -1176,6 +1320,7 @@ void Stream::Destroy(QuicError error) { // which may keep that data alive a bit longer. inbound_->removeBackpressureListener(this); inbound_.reset(); + reader_.reset(); // Notify the JavaScript side that our handle is being destroyed. The // JavaScript side should clean up any state that it needs to and should @@ -1210,12 +1355,16 @@ void Stream::ReceiveData(const uint8_t* data, } STAT_INCREMENT_N(Stats, bytes_received, len); + STAT_SET(Stats, max_offset_received, STAT_GET(Stats, bytes_received)); STAT_RECORD_TIMESTAMP(Stats, received_at); JS_TRY_ALLOCATE_BACKING(env(), backing, len) memcpy(backing->Data(), data, len); inbound_->append(DataQueue::CreateInMemoryEntryFromBackingStore( std::move(backing), 0, len)); + // Notify the JS reader that data is available. + if (reader_) reader_->NotifyPull(); + if (flags.fin) EndReadable(); } @@ -1313,10 +1462,6 @@ void Stream::Schedule(Queue* queue) { if (outbound_ && stream_queue_.IsEmpty()) queue->PushBack(this); } -void Stream::Unschedule() { - Debug(this, "Unscheduled"); - stream_queue_.Remove(); -} } // namespace quic } // namespace node diff --git a/src/quic/streams.h b/src/quic/streams.h index c230815d78e4be..610aac2de334f4 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -37,9 +37,9 @@ using Ngtcp2Source = bob::SourceImpl; // Note that only locally initiated streams can be created in a pending state. class PendingStream final { public: - PendingStream(Direction direction, - Stream* stream, - BaseObjectWeakPtr session); + explicit PendingStream(Direction direction, + Stream* stream, + BaseObjectWeakPtr session); DISALLOW_COPY_AND_MOVE(PendingStream) ~PendingStream(); @@ -233,7 +233,7 @@ class Stream final : public AsyncWrap, // indication occuring the first time data is sent. It does not indicate // that the data has been retransmitted due to loss or has been // acknowledged to have been received by the peer. - void Commit(size_t datalen); + void Commit(size_t datalen, bool fin = false); void EndWritable(); void EndReadable(std::optional maybe_final_size = std::nullopt); @@ -280,6 +280,8 @@ class Stream final : public AsyncWrap, // have already been added, or the maximum total header length is reached. bool AddHeader(const Header& header); + // TODO(@jasnell): Implement MemoryInfo to track outbound_, inbound_, + // reader_, headers_, and pending_headers_queue_. SET_NO_MEMORY_INFO() SET_MEMORY_INFO_NAME(Stream) SET_SELF_SIZE(Stream) @@ -299,6 +301,11 @@ class Stream final : public AsyncWrap, void set_final_size(uint64_t amount); void set_outbound(std::shared_ptr source); + // Streaming outbound support + void InitStreaming(); + void WriteStreamData(const v8::FunctionCallbackInfo& args); + void EndWriting(); + bool is_local_unidirectional() const; bool is_remote_unidirectional() const; @@ -337,6 +344,7 @@ class Stream final : public AsyncWrap, BaseObjectWeakPtr session_; std::unique_ptr outbound_; std::shared_ptr inbound_; + BaseObjectWeakPtr reader_; // If the stream cannot be opened yet, it will be created in a pending state. // Once the owning session is able to, it will complete opening of the stream @@ -372,7 +380,7 @@ class Stream final : public AsyncWrap, friend class DefaultApplication; public: - // The Queue/Schedule/Unschedule here are part of the mechanism used to + // The Queue/Schedule here are part of the mechanism used to // determine which streams have data to send on the session. When a stream // potentially has data available, it will be scheduled in the Queue. Then, // when the Session::Application starts sending pending data, it will check @@ -385,7 +393,6 @@ class Stream final : public AsyncWrap, using Queue = ListHead; void Schedule(Queue* queue); - void Unschedule(); }; } // namespace node::quic diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index cfe22e2c485a6b..ce387bd656c07f 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -43,16 +43,6 @@ namespace quic { // ============================================================================ namespace { -// Performance optimization recommended by ngtcp2. Need to investigate why -// this causes some tests to fail. -// auto _ = []() { -// if (ngtcp2_crypto_ossl_init() != 0) { -// assert(0); -// abort(); -// } - -// return 0; -// }(); // Temporarily wraps an SSL pointer but does not take ownership. // Use by a few of the TLSSession methods that need access to the SSL* @@ -99,7 +89,7 @@ void EnableTrace(Environment* env, BIOPointer* bio, SSL* ssl) { #endif } -template Opt::*member> +template Opt::* member> bool SetOption(Environment* env, Opt* options, const Local& object, @@ -230,7 +220,8 @@ std::string OSSLContext::get_selected_alpn() const { const unsigned char* alpn = nullptr; unsigned int len; SSL_get0_alpn_selected(*this, &alpn, &len); - return std::string(alpn, alpn + len); + if (alpn == nullptr) return {}; + return std::string(reinterpret_cast(alpn), len); } std::string_view OSSLContext::get_negotiated_group() const { @@ -267,6 +258,12 @@ bool OSSLContext::get_early_data_accepted() const { return SSL_get_early_data_status(*this) == SSL_EARLY_DATA_ACCEPTED; } +bool OSSLContext::set_session_ticket(const ncrypto::SSLSessionPointer& ticket) { + if (!ticket) return false; + if (SSL_set_session(*this, ticket.get()) != 1) return false; + return SSL_SESSION_get_max_early_data(ticket.get()) != 0; +} + bool OSSLContext::ConfigureServer() const { if (ngtcp2_crypto_ossl_configure_server_session(*this) != 0) return false; SSL_set_accept_state(*this); @@ -366,8 +363,40 @@ void TLSContext::OnKeylog(const SSL* ssl, const char* line) { int TLSContext::OnVerifyClientCertificate(int preverify_ok, X509_STORE_CTX* ctx) { - // TODO(@jasnell): Implement the logic to verify the client certificate - return 1; + // This callback is invoked by OpenSSL for each certificate in the + // client's chain during the TLS handshake. The preverify_ok + // parameter reflects OpenSSL's own chain validation result for + // the current certificate. Failures include: + // - Expired or not-yet-valid certificates + // - Self-signed certificates not in the trusted CA list + // - Broken chain (signature verification failure) + // - Untrusted CA (chain does not terminate at a configured CA) + // - Revoked certificates (if CRL is configured) + // - Invalid basic constraints or key usage + // + // If preverify_ok is 1, validation passed for this cert and we + // always continue. If it is 0, the behavior depends on the + // reject_unauthorized option: + // - true (default): return 0 to abort the handshake immediately, + // avoiding wasted work on an untrusted client. + // - false: return 1 to let the handshake complete. The validation + // error is still recorded by OpenSSL and will be reported to JS + // via VerifyPeerIdentity() in the handshake callback, allowing + // the application to make its own decision. + // + // Note that even when preverify_ok is 1 (chain validation passed), + // the application may need to perform additional verification after + // the handshake — for example, checking the certificate's common + // name or subject alternative names against an allowlist, verifying + // application-specific fields or extensions, or enforcing certificate + // pinning. Chain validation only confirms cryptographic integrity + // and trust anchor; it does not confirm authorization. + if (preverify_ok) return 1; + + SSL* ssl = static_cast( + X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx())); + auto& tls_session = TLSSession::From(ssl); + return tls_session.context().options().reject_unauthorized ? 0 : 1; } std::unique_ptr TLSContext::NewSession( @@ -389,12 +418,17 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { return {}; } - if (SSL_CTX_set_max_early_data(ctx.get(), UINT32_MAX) != 1) { + if (SSL_CTX_set_max_early_data( + ctx.get(), + options_.enable_early_data ? UINT32_MAX : 0) != 1) { validation_error_ = "Failed to set max early data"; return {}; } + // ngtcp2 handles replay protection at the QUIC layer, + // so we disable OpenSSL's built-in anti-replay. SSL_CTX_set_options(ctx.get(), - (SSL_OP_ALL & ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | + (SSL_OP_ALL & + ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE | SSL_OP_NO_ANTI_REPLAY); @@ -408,20 +442,18 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { return {}; } - if (options_.verify_client) [[likely]] { + if (options_.verify_client) [[unlikely]] { SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER | SSL_VERIFY_CLIENT_ONCE | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, OnVerifyClientCertificate); } - // TODO(@jasnell): There's a bug int the GenerateCallback flow somewhere. - // Need to update in order to support session tickets. - // CHECK_EQ(SSL_CTX_set_session_ticket_cb(ctx.get(), - // SessionTicket::GenerateCallback, - // SessionTicket::DecryptedCallback, - // nullptr), - // 1); + CHECK_EQ(SSL_CTX_set_session_ticket_cb(ctx.get(), + SessionTicket::GenerateCallback, + SessionTicket::DecryptedCallback, + nullptr), + 1); break; } case Side::CLIENT: { @@ -582,11 +614,13 @@ Maybe TLSContext::Options::From(Environment* env, SetOption( \ env, &options, params, state.name##_string()) - if (!SET(verify_client) || !SET(enable_tls_trace) || !SET(protocol) || - !SET(servername) || !SET(ciphers) || !SET(groups) || - !SET(verify_private_key) || !SET(keylog) || - !SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) || - !SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) { + if (!SET(verify_client) || !SET(reject_unauthorized) || + !SET(enable_early_data) || !SET(enable_tls_trace) || + !SET(enable_tls_trace) || !SET(protocol) || !SET(servername) || + !SET(ciphers) || !SET(groups) || !SET(verify_private_key) || + !SET(keylog) || !SET_VECTOR(crypto::KeyObjectData, keys) || + !SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) || + !SET_VECTOR(Store, crl)) { return Nothing(); } @@ -603,6 +637,10 @@ std::string TLSContext::Options::ToString() const { prefix + "keylog: " + (keylog ? std::string("yes") : std::string("no")); res += prefix + "verify client: " + (verify_client ? std::string("yes") : std::string("no")); + res += prefix + "reject unauthorized: " + + (reject_unauthorized ? std::string("yes") : std::string("no")); + res += prefix + "enable early data: " + + (enable_early_data ? std::string("yes") : std::string("no")); res += prefix + "enable_tls_trace: " + (enable_tls_trace ? std::string("yes") : std::string("no")); res += prefix + "verify private key: " + @@ -712,8 +750,7 @@ void TLSSession::Initialize( reinterpret_cast(buf.base), buf.len); // The early data will just be ignored if it's invalid. - if (ssl.setSession(ticket) && - SSL_SESSION_get_max_early_data(ticket.get()) != 0) { + if (ossl_context_.set_session_ticket(ticket)) { ngtcp2_vec rtp = sessionTicket.transport_params(); if (ngtcp2_conn_decode_and_set_0rtt_transport_params( *session_, rtp.base, rtp.len) == 0) { diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index d900e5b825aa60..4b64b6c7238b30 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -55,6 +55,10 @@ class OSSLContext final { bool get_early_data_accepted() const; + // Sets the session ticket for 0-RTT resumption. Returns true if the + // ticket was set successfully and the ticket supports early data. + bool set_session_ticket(const ncrypto::SSLSessionPointer& ticket); + bool ConfigureServer() const; bool ConfigureClient() const; @@ -190,6 +194,21 @@ class TLSContext final : public MemoryRetainer, // option is only used by the server side. bool verify_client = false; + // When true (the default), client certificates that fail chain + // validation are rejected during the handshake. When false, the + // handshake completes and the validation result is passed to JS + // via the handshake callback for the application to decide. + // This option is only used by the server side. + bool reject_unauthorized = true; + + // When true (the default), the server accepts 0-RTT early data + // from clients with valid session tickets. When false, early data + // is disabled and clients must complete a full handshake before + // sending application data. Disabling early data prevents replay + // attacks at the cost of an additional round trip. + // This option is only used by the server side. + bool enable_early_data = true; + // When true, enables TLS tracing for the session. This should only be used // for debugging. // JavaScript option name "tlsTrace". diff --git a/src/quic/tokens.cc b/src/quic/tokens.cc index 962e321786fe08..1019b43a534809 100644 --- a/src/quic/tokens.cc +++ b/src/quic/tokens.cc @@ -1,7 +1,6 @@ #if HAVE_OPENSSL && HAVE_QUIC #include "guard.h" #ifndef OPENSSL_NO_QUIC -#include "tokens.h" #include #include #include @@ -10,6 +9,7 @@ #include #include "nbytes.h" #include "ncrypto.h" +#include "tokens.h" namespace node::quic { @@ -108,7 +108,7 @@ bool StatelessResetToken::operator==(const StatelessResetToken& other) const { (ptr_ != nullptr && other.ptr_ == nullptr)) { return false; } - return memcmp(ptr_, other.ptr_, kStatelessTokenLen) == 0; + return CRYPTO_memcmp(ptr_, other.ptr_, kStatelessTokenLen) == 0; } bool StatelessResetToken::operator!=(const StatelessResetToken& other) const { @@ -126,11 +126,12 @@ std::string StatelessResetToken::ToString() const { size_t StatelessResetToken::Hash::operator()( const StatelessResetToken& token) const { + // See CID::Hash for details on this hash combine strategy. size_t hash = 0; if (token.ptr_ == nullptr) return hash; - for (size_t n = 0; n < kStatelessTokenLen; n++) - hash ^= std::hash{}(token.ptr_[n]) + 0x9e3779b9 + (hash << 6) + - (hash >> 2); + for (size_t n = 0; n < kStatelessTokenLen; n++) { + hash ^= token.ptr_[n] + 0x9e3779b9 + (hash << 6) + (hash >> 2); + } return hash; } @@ -195,7 +196,7 @@ RetryToken::RetryToken(uint32_t version, RetryToken::RetryToken(const uint8_t* token, size_t size) : ptr_(ngtcp2_vec{const_cast(token), size}) { DCHECK_LE(size, RetryToken::kRetryTokenLen); - DCHECK_IMPLIES(token == nullptr, size = 0); + DCHECK_IMPLIES(token == nullptr, size == 0); } std::optional RetryToken::Validate(uint32_t version, @@ -215,7 +216,9 @@ std::optional RetryToken::Validate(uint32_t version, addr.data(), addr.length(), dcid, - std::min(verification_expiration, QUIC_MIN_RETRYTOKEN_EXPIRATION), + std::clamp(verification_expiration, + QUIC_MIN_RETRYTOKEN_EXPIRATION, + QUIC_MAX_RETRYTOKEN_EXPIRATION), uv_hrtime()); if (ret != 0) return std::nullopt; return std::optional(ocid); @@ -256,7 +259,7 @@ RegularToken::RegularToken(uint32_t version, RegularToken::RegularToken(const uint8_t* token, size_t size) : ptr_(ngtcp2_vec{const_cast(token), size}) { DCHECK_LE(size, RegularToken::kRegularTokenLen); - DCHECK_IMPLIES(token == nullptr, size = 0); + DCHECK_IMPLIES(token == nullptr, size == 0); } RegularToken::operator bool() const { @@ -275,8 +278,9 @@ bool RegularToken::Validate(uint32_t version, TokenSecret::QUIC_TOKENSECRET_LEN, addr.data(), addr.length(), - std::min(verification_expiration, - QUIC_MIN_REGULARTOKEN_EXPIRATION), + std::clamp(verification_expiration, + QUIC_MIN_REGULARTOKEN_EXPIRATION, + QUIC_MAX_REGULARTOKEN_EXPIRATION), uv_hrtime()) == 0; } diff --git a/src/quic/tokens.h b/src/quic/tokens.h index 5949cd58640d15..cfbaa94e344f8d 100644 --- a/src/quic/tokens.h +++ b/src/quic/tokens.h @@ -161,6 +161,8 @@ class RetryToken final : public MemoryRetainer { static constexpr uint64_t QUIC_DEFAULT_RETRYTOKEN_EXPIRATION = 10 * NGTCP2_SECONDS; static constexpr uint64_t QUIC_MIN_RETRYTOKEN_EXPIRATION = 1 * NGTCP2_SECONDS; + static constexpr uint64_t QUIC_MAX_RETRYTOKEN_EXPIRATION = + 60 * NGTCP2_SECONDS; // Generates a new retry token. RetryToken(uint32_t version, @@ -214,18 +216,20 @@ class RegularToken final : public MemoryRetainer { 10 * NGTCP2_SECONDS; static constexpr uint64_t QUIC_MIN_REGULARTOKEN_EXPIRATION = 1 * NGTCP2_SECONDS; + static constexpr uint64_t QUIC_MAX_REGULARTOKEN_EXPIRATION = + 5 * 60 * NGTCP2_SECONDS; RegularToken(); - // Generates a new retry token. + // Generates a new regular token. RegularToken(uint32_t version, const SocketAddress& address, const TokenSecret& token_secret); - // Wraps the given retry token + // Wraps the given regular token RegularToken(const uint8_t* token, size_t length); - // Validates the retry token given the input. + // Validates the regular token given the input. bool Validate( uint32_t version, const SocketAddress& address, @@ -240,8 +244,8 @@ class RegularToken final : public MemoryRetainer { std::string ToString() const; SET_NO_MEMORY_INFO() - SET_MEMORY_INFO_NAME(RetryToken) - SET_SELF_SIZE(RetryToken) + SET_MEMORY_INFO_NAME(RegularToken) + SET_SELF_SIZE(RegularToken) private: operator const char*() const; diff --git a/src/quic/transportparams.h b/src/quic/transportparams.h index ff37299c27ddb5..67e1e5deec00fb 100644 --- a/src/quic/transportparams.h +++ b/src/quic/transportparams.h @@ -120,7 +120,8 @@ class TransportParams final { // connection migration. See the QUIC specification for more details on // connection migration. // https://www.rfc-editor.org/rfc/rfc9000.html#section-18.2-4.30.1 - // TODO(@jasnell): We currently do not implement active migration. + // TODO(@jasnell): Active connection migration is not yet implemented. + // This will be revisited in a future update. bool disable_active_migration = true; static const Options kDefault; diff --git a/test/cctest/test_quic_preferredaddress.cc b/test/cctest/test_quic_preferredaddress.cc index 0b7abc423f5abd..a1a1ad2ed007b5 100644 --- a/test/cctest/test_quic_preferredaddress.cc +++ b/test/cctest/test_quic_preferredaddress.cc @@ -48,7 +48,7 @@ TEST(PreferredAddress, Basic) { const auto ipv4 = preferred_address.ipv4().value(); CHECK_EQ(ipv4.family, AF_INET); CHECK_EQ(htons(ipv4.port), 443); - CHECK_EQ(ipv4.address, "123.123.123.123"); + CHECK_EQ(std::string(ipv4.host), "123.123.123.123"); memcpy(&paddr.ipv6, &storage6, sizeof(sockaddr_in6)); paddr.ipv6_present = 1; @@ -57,7 +57,7 @@ TEST(PreferredAddress, Basic) { const auto ipv6 = preferred_address.ipv6().value(); CHECK_EQ(ipv6.family, AF_INET6); CHECK_EQ(htons(ipv6.port), 123); - CHECK_EQ(ipv6.address, "2001:db8::1"); + CHECK_EQ(std::string(ipv6.host), "2001:db8::1"); CHECK_EQ(preferred_address.cid(), cid); } @@ -78,7 +78,7 @@ TEST(PreferredAddress, SetTransportParams) { const auto ipv4_2 = paddr2.ipv4().value(); CHECK_EQ(ipv4_2.family, AF_INET); CHECK_EQ(htons(ipv4_2.port), 443); - CHECK_EQ(ipv4_2.address, "123.123.123.123"); + CHECK_EQ(std::string(ipv4_2.host), "123.123.123.123"); } #endif // OPENSSL_NO_QUIC #endif // HAVE_OPENSSL && HAVE_QUIC diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index 52ed8ba5275301..94b8167c2d751a 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -138,7 +138,6 @@ assert.strictEqual(streamState.finSent, false); assert.strictEqual(streamState.finReceived, false); assert.strictEqual(streamState.readEnded, false); assert.strictEqual(streamState.writeEnded, false); -assert.strictEqual(streamState.paused, false); assert.strictEqual(streamState.reset, false); assert.strictEqual(streamState.hasReader, false); assert.strictEqual(streamState.wantsBlock, false); diff --git a/test/parallel/test-quic-internal-setcallbacks.mjs b/test/parallel/test-quic-internal-setcallbacks.mjs index 88fb2624dd33f6..cebbee43376d6e 100644 --- a/test/parallel/test-quic-internal-setcallbacks.mjs +++ b/test/parallel/test-quic-internal-setcallbacks.mjs @@ -18,6 +18,7 @@ const callbacks = { onSessionHandshake() {}, onSessionPathValidation() {}, onSessionTicket() {}, + onSessionNewToken() {}, onSessionVersionNegotiation() {}, onStreamCreated() {}, onStreamBlocked() {}, From 79b960a2bcecd3192f12c42fa68a1f85ce31bda9 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 21 Mar 2026 20:25:31 -0700 Subject: [PATCH 2/7] quic: update http3 impl details Signed-off-by: James M Snell Assisted-by: Opencode/Opus 4.6 PR-URL: https://github.com/nodejs/node/pull/62387 Reviewed-By: Stephen Belanger --- src/quic/http3.cc | 137 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 111 insertions(+), 26 deletions(-) diff --git a/src/quic/http3.cc b/src/quic/http3.cc index d2da2a836eadde..805ea531148ee5 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -319,14 +319,20 @@ class Http3ApplicationImpl final : public Session::Application { void CollectSessionTicketAppData( SessionTicket::AppData* app_data) const override { - // TODO(@jasnell): There's currently nothing to store but there may be - // later. + // TODO(@jasnell): When HTTP/3 settings become dynamic or + // configurable per-connection, store them here so they can be + // validated on 0-RTT resumption. Candidates include: + // max_field_section_size, qpack_max_dtable_capacity, + // qpack_encoder_max_dtable_capacity, qpack_blocked_streams, + // enable_connect_protocol, and enable_datagrams. On extraction, + // compare stored values against current settings and return + // TICKET_IGNORE_RENEW if incompatible. } SessionTicket::AppData::Status ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, SessionTicket::AppData::Source::Flag flag) override { - // There's currently nothing stored here but we might do so later. + // See CollectSessionTicketAppData above. return flag == SessionTicket::AppData::Source::Flag::STATUS_RENEW ? SessionTicket::AppData::Status::TICKET_USE_RENEW : SessionTicket::AppData::Status::TICKET_USE; @@ -448,10 +454,42 @@ class Http3ApplicationImpl final : public Session::Application { return false; } + void SetStreamPriority(const Stream& stream, + StreamPriority priority, + StreamPriorityFlags flags) override { + nghttp3_pri pri; + pri.inc = (flags == StreamPriorityFlags::NON_INCREMENTAL) ? 0 : 1; + switch (priority) { + case StreamPriority::HIGH: + pri.urgency = NGHTTP3_URGENCY_HIGH; + break; + case StreamPriority::LOW: + pri.urgency = NGHTTP3_URGENCY_LOW; + break; + default: + pri.urgency = NGHTTP3_DEFAULT_URGENCY; + break; + } + if (session().is_server()) { + nghttp3_conn_set_server_stream_priority( + *this, stream.id(), &pri); + } + // Client-side priority is set at request submission time via + // nghttp3_conn_submit_request and is not typically changed + // after the fact. The client API takes a serialized RFC 9218 + // field value rather than an nghttp3_pri struct. + } + StreamPriority GetStreamPriority(const Stream& stream) override { nghttp3_pri pri; if (nghttp3_conn_get_stream_priority(*this, &pri, stream.id()) == 0) { - // TODO(@jasnell): Support the incremental flag + // TODO(@jasnell): The nghttp3_pri.inc (incremental) flag is + // not yet exposed. When priority-based stream scheduling is + // implemented, GetStreamPriority should return both urgency + // and the incremental flag (making get/set symmetrical). + // The inc flag determines whether the server should interleave + // data from this stream with others of the same urgency + // (inc=1) or complete it first (inc=0). switch (pri.urgency) { case NGHTTP3_URGENCY_HIGH: return StreamPriority::HIGH; @@ -673,22 +711,23 @@ class Http3ApplicationImpl final : public Session::Application { stream->ReceiveStreamReset(0, QuicError::ForApplication(app_error_code)); } - void OnShutdown() { - // This callback is invoked when we receive a request to gracefully shutdown - // the http3 connection. For client, the id is the stream id of a client - // initiated stream. For server, the id is the stream id of a server - // initiated stream. Once received, the other side is guaranteed not to - // process any more data. - - // On the client side, if id is equal to NGHTTP3_SHUTDOWN_NOTICE_STREAM_ID, - // or on the server if the id is equal to NGHTTP3_SHUTDOWN_NOTICE_PUSH_ID, - // then this is a request to begin a graceful shutdown. - - // This can be called multiple times but the id can only stay the same or - // *decrease*. - - // TODO(@jasnell): Need to determine exactly how to handle. - Debug(&session(), "HTTP/3 application received shutdown notice"); + void OnShutdown(int64_t id) { + // The peer has sent a GOAWAY frame initiating a graceful shutdown. + // For a client, id is the stream ID beyond which the server will + // not process requests. For a server, id is a push ID (server + // push is not implemented). Streams/pushes with IDs >= id will + // not be processed by the peer. + // + // When id equals NGHTTP3_SHUTDOWN_NOTICE_STREAM_ID (client) or + // NGHTTP3_SHUTDOWN_NOTICE_PUSH_ID (server), this is a notice of + // intent to shut down rather than an immediate refusal. + // + // This can be called multiple times with a decreasing id as the + // peer progressively reduces the set of streams it will process. + Debug(&session(), + "HTTP/3 received GOAWAY (id=%" PRIi64 ")", + id); + session().Close(Session::CloseMethod::GRACEFUL); } void OnReceiveSettings(const nghttp3_settings* settings) { @@ -747,9 +786,55 @@ class Http3ApplicationImpl final : public Session::Application { uint32_t* pflags, void* conn_user_data, void* stream_user_data) { - return NGTCP2_SUCCESS; + auto ptr = From(conn, conn_user_data); + CHECK_NOT_NULL(ptr); + auto& app = *ptr; + NgHttp3CallbackScope scope(app.env()); + + auto stream = app.session().FindStream(stream_id); + if (!stream) return NGHTTP3_ERR_CALLBACK_FAILURE; + + if (stream->is_eos()) { + *pflags |= NGHTTP3_DATA_FLAG_EOF; + return 0; + } + + size_t max_count = std::min(veccnt, + static_cast(kMaxVectorCount)); + nghttp3_ssize result = 0; + + auto next = [&](int status, + const ngtcp2_vec* data, + size_t count, + bob::Done done) { + switch (status) { + case bob::Status::STATUS_BLOCK: + case bob::Status::STATUS_WAIT: + result = NGHTTP3_ERR_WOULDBLOCK; + return; + case bob::Status::STATUS_EOS: + *pflags |= NGHTTP3_DATA_FLAG_EOF; + break; + } + count = std::min(count, max_count); + for (size_t n = 0; n < count; n++) { + vec[n].base = data[n].base; + vec[n].len = data[n].len; + } + result = static_cast(count); + }; + + ngtcp2_vec data[kMaxVectorCount]; + stream->Pull(std::move(next), + bob::Options::OPTIONS_SYNC, + data, + max_count, + max_count); + + return result; } + static int on_acked_stream_data(nghttp3_conn* conn, int64_t stream_id, uint64_t datalen, @@ -934,7 +1019,7 @@ class Http3ApplicationImpl final : public Session::Application { static int on_shutdown(nghttp3_conn* conn, int64_t id, void* conn_user_data) { NGHTTP3_CALLBACK_SCOPE(app); - app.OnShutdown(); + app.OnShutdown(id); return NGTCP2_SUCCESS; } @@ -950,14 +1035,14 @@ class Http3ApplicationImpl final : public Session::Application { const uint8_t* origin, size_t originlen, void* conn_user_data) { - // TODO(@jasnell): Handle the origin callback. This is called - // when a single origin in an ORIGIN frame is received. + // ORIGIN frames (RFC 8336) are used for connection coalescing + // across multiple origins. Not yet implemented u2014 requires + // connection pooling and multi-origin reuse support. return NGTCP2_SUCCESS; } static int on_end_origin(nghttp3_conn* conn, void* conn_user_data) { - // TODO(@jasnell): Handle the end of origin callback. This is called - // when the end of an ORIGIN frame is received. + // See on_receive_origin above. return NGTCP2_SUCCESS; } From 1a88acbfa213ea63238c6adde30d965d0b32790c Mon Sep 17 00:00:00 2001 From: James M Snell Date: Mon, 23 Mar 2026 06:50:25 -0700 Subject: [PATCH 3/7] quic: fixup linting/formatting issues Signed-off-by: James M Snell Assisted-by: Opencode/Opus 4.6 PR-URL: https://github.com/nodejs/node/pull/62387 Reviewed-By: Stephen Belanger --- src/node_blob.cc | 3 +- src/quic/application.cc | 4 +-- src/quic/application.h | 6 ++-- src/quic/bindingdata.h | 2 +- src/quic/endpoint.cc | 14 +++----- src/quic/endpoint.h | 1 - src/quic/http3.cc | 65 ++++++++++++++++-------------------- src/quic/preferredaddress.cc | 11 +++--- src/quic/session.cc | 47 ++++++++++++-------------- src/quic/sessionticket.cc | 8 ++--- src/quic/streams.cc | 23 +++++-------- src/quic/tlscontext.cc | 10 +++--- 12 files changed, 84 insertions(+), 110 deletions(-) diff --git a/src/node_blob.cc b/src/node_blob.cc index fb85d5472e0248..00deb82f46c322 100644 --- a/src/node_blob.cc +++ b/src/node_blob.cc @@ -411,8 +411,7 @@ void Blob::Reader::Pull(const FunctionCallbackInfo& args) { std::move(next), node::bob::OPTIONS_END, nullptr, 0)); } -void Blob::Reader::SetWakeup( - const FunctionCallbackInfo& args) { +void Blob::Reader::SetWakeup(const FunctionCallbackInfo& args) { Blob::Reader* reader; ASSIGN_OR_RETURN_UNWRAP(&reader, args.This()); CHECK(args[0]->IsFunction()); diff --git a/src/quic/application.cc b/src/quic/application.cc index d4daeccc14fd1c..f5f1f02227c5b7 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -452,7 +452,7 @@ ssize_t Session::Application::WriteVStream(PathStorage* path, if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; return ngtcp2_conn_writev_stream(*session_, &path->path, - // TODO(@jasnell): ECN blocked on libuv + // TODO(@jasnell): ECN blocked on libuv nullptr, dest, max_packet_size, @@ -583,7 +583,6 @@ class DefaultApplication final : public Session::Application { void ResumeStream(int64_t id) override { ScheduleStream(id); } - void BlockStream(int64_t id) override { if (auto stream = session().FindStream(id)) [[likely]] { stream->EmitBlocked(); @@ -608,7 +607,6 @@ class DefaultApplication final : public Session::Application { } } - Stream::Queue stream_queue_; }; diff --git a/src/quic/application.h b/src/quic/application.h index 52e5f314518c60..76f81b71557888 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -155,9 +155,9 @@ struct Session::Application::StreamData final { BaseObjectPtr stream; static_assert(sizeof(ngtcp2_vec) == sizeof(nghttp3_vec) && - alignof(ngtcp2_vec) == alignof(nghttp3_vec) && - offsetof(ngtcp2_vec, base) == offsetof(nghttp3_vec, base) && - offsetof(ngtcp2_vec, len) == offsetof(nghttp3_vec, len), + alignof(ngtcp2_vec) == alignof(nghttp3_vec) && + offsetof(ngtcp2_vec, base) == offsetof(nghttp3_vec, base) && + offsetof(ngtcp2_vec, len) == offsetof(nghttp3_vec, len), "ngtcp2_vec and nghttp3_vec must have identical layout"); inline operator nghttp3_vec*() { return reinterpret_cast(data); diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 9e778eee408ec3..462257ebb4823b 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -43,7 +43,7 @@ class Packet; V(session_datagram_status, SessionDatagramStatus) \ V(session_handshake, SessionHandshake) \ V(session_new, SessionNew) \ - V(session_new_token, SessionNewToken) \ + V(session_new_token, SessionNewToken) \ V(session_path_validation, SessionPathValidation) \ V(session_ticket, SessionTicket) \ V(session_version_negotiation, SessionVersionNegotiation) \ diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index bd99856ca67d1a..533a5d01ceff10 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -324,8 +324,7 @@ class Endpoint::UDP::Impl final : public HandleWrap { DCHECK_NOT_NULL(impl->endpoint_); auto release_buf = [&]() { - if (buf->base != nullptr) - impl->env()->release_managed_buffer(*buf); + if (buf->base != nullptr) impl->env()->release_managed_buffer(*buf); }; // Nothing to do in these cases. Specifically, if the nread @@ -343,9 +342,8 @@ class Endpoint::UDP::Impl final : public HandleWrap { return; } - impl->endpoint_->Receive( - uv_buf_init(buf->base, static_cast(nread)), - SocketAddress(addr)); + impl->endpoint_->Receive(uv_buf_init(buf->base, static_cast(nread)), + SocketAddress(addr)); } uv_udp_t handle_; @@ -999,7 +997,6 @@ void Endpoint::Destroy(CloseContext context, int status) { this, "Destroying endpoint due to \"%s\" with status %d", ctx, status); } - state_->listening = 0; close_context_ = context; @@ -1365,9 +1362,8 @@ void Endpoint::Receive(const uv_buf_t& buf, // trusted networks), we skip the Retry and allow 0-RTT to // proceed without additional validation. if (options_.validate_address) { - Debug(this, - "Sending retry to %s due to 0RTT packet", - remote_address); + Debug( + this, "Sending retry to %s due to 0RTT packet", remote_address); SendRetry(PathDescriptor{ version, dcid, diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h index 7ed9fee81e46b6..c445069acb5d49 100644 --- a/src/quic/endpoint.h +++ b/src/quic/endpoint.h @@ -311,7 +311,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // be prevented. void CloseGracefully(); - void PacketDone(int status) override; void EmitNewSession(const BaseObjectPtr& session); diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 805ea531148ee5..3319d1de54e3c4 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -455,8 +455,8 @@ class Http3ApplicationImpl final : public Session::Application { } void SetStreamPriority(const Stream& stream, - StreamPriority priority, - StreamPriorityFlags flags) override { + StreamPriority priority, + StreamPriorityFlags flags) override { nghttp3_pri pri; pri.inc = (flags == StreamPriorityFlags::NON_INCREMENTAL) ? 0 : 1; switch (priority) { @@ -471,8 +471,7 @@ class Http3ApplicationImpl final : public Session::Application { break; } if (session().is_server()) { - nghttp3_conn_set_server_stream_priority( - *this, stream.id(), &pri); + nghttp3_conn_set_server_stream_priority(*this, stream.id(), &pri); } // Client-side priority is set at request submission time via // nghttp3_conn_submit_request and is not typically changed @@ -536,12 +535,10 @@ class Http3ApplicationImpl final : public Session::Application { nghttp3_err_infer_quic_app_error_code(err))); return false; } - if (data->stream) - data->stream->Commit(datalen, data->fin); + if (data->stream) data->stream->Commit(datalen, data->fin); return true; } - SET_NO_MEMORY_INFO() SET_MEMORY_INFO_NAME(Http3ApplicationImpl) SET_SELF_SIZE(Http3ApplicationImpl) @@ -724,9 +721,7 @@ class Http3ApplicationImpl final : public Session::Application { // // This can be called multiple times with a decreasing id as the // peer progressively reduces the set of streams it will process. - Debug(&session(), - "HTTP/3 received GOAWAY (id=%" PRIi64 ")", - id); + Debug(&session(), "HTTP/3 received GOAWAY (id=%" PRIi64 ")", id); session().Close(Session::CloseMethod::GRACEFUL); } @@ -799,42 +794,38 @@ class Http3ApplicationImpl final : public Session::Application { return 0; } - size_t max_count = std::min(veccnt, - static_cast(kMaxVectorCount)); + size_t max_count = std::min(veccnt, static_cast(kMaxVectorCount)); nghttp3_ssize result = 0; - auto next = [&](int status, - const ngtcp2_vec* data, - size_t count, - bob::Done done) { - switch (status) { - case bob::Status::STATUS_BLOCK: - case bob::Status::STATUS_WAIT: - result = NGHTTP3_ERR_WOULDBLOCK; - return; - case bob::Status::STATUS_EOS: - *pflags |= NGHTTP3_DATA_FLAG_EOF; - break; - } - count = std::min(count, max_count); - for (size_t n = 0; n < count; n++) { - vec[n].base = data[n].base; - vec[n].len = data[n].len; - } - result = static_cast(count); - }; + auto next = + [&](int status, const ngtcp2_vec* data, size_t count, bob::Done done) { + switch (status) { + case bob::Status::STATUS_BLOCK: + case bob::Status::STATUS_WAIT: + result = NGHTTP3_ERR_WOULDBLOCK; + return; + case bob::Status::STATUS_EOS: + *pflags |= NGHTTP3_DATA_FLAG_EOF; + break; + } + count = std::min(count, max_count); + for (size_t n = 0; n < count; n++) { + vec[n].base = data[n].base; + vec[n].len = data[n].len; + } + result = static_cast(count); + }; ngtcp2_vec data[kMaxVectorCount]; stream->Pull(std::move(next), - bob::Options::OPTIONS_SYNC, - data, - max_count, - max_count); + bob::Options::OPTIONS_SYNC, + data, + max_count, + max_count); return result; } - static int on_acked_stream_data(nghttp3_conn* conn, int64_t stream_id, uint64_t datalen, diff --git a/src/quic/preferredaddress.cc b/src/quic/preferredaddress.cc index 51dddd5ecd7f94..3d584be2d74811 100644 --- a/src/quic/preferredaddress.cc +++ b/src/quic/preferredaddress.cc @@ -30,14 +30,17 @@ std::optional get_address_info( if constexpr (FAMILY == AF_INET) { if (!paddr.ipv4_present) return std::nullopt; address.port = paddr.ipv4.sin_port; - if (uv_inet_ntop(FAMILY, &paddr.ipv4.sin_addr, - address.host, sizeof(address.host)) != 0) + if (uv_inet_ntop( + FAMILY, &paddr.ipv4.sin_addr, address.host, sizeof(address.host)) != + 0) return std::nullopt; } else { if (!paddr.ipv6_present) return std::nullopt; address.port = paddr.ipv6.sin6_port; - if (uv_inet_ntop(FAMILY, &paddr.ipv6.sin6_addr, - address.host, sizeof(address.host)) != 0) + if (uv_inet_ntop(FAMILY, + &paddr.ipv6.sin6_addr, + address.host, + sizeof(address.host)) != 0) return std::nullopt; } return address; diff --git a/src/quic/session.cc b/src/quic/session.cc index 351d5c61a417b8..180d5a1b4dd555 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -475,8 +475,7 @@ Maybe Session::Options::From(Environment* env, // Parse the optional NEW_TOKEN for address validation on reconnection. Local token_val; - if (params->Get(env->context(), state.token_string()) - .ToLocal(&token_val) && + if (params->Get(env->context(), state.token_string()).ToLocal(&token_val) && token_val->IsArrayBufferView()) { Store token_store; if (Store::From(token_val.As()).To(&token_store)) { @@ -897,9 +896,8 @@ struct Session::Impl final : public MemoryRetainer { uint64_t max_streams, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) - Debug(session, - "Max remote bidi streams increased to %" PRIu64, - max_streams); + Debug( + session, "Max remote bidi streams increased to %" PRIu64, max_streams); return NGTCP2_SUCCESS; } @@ -1630,11 +1628,14 @@ bool Session::Receive(Store&& store, // ngtcp2_conn_read_pkt here, we will need to double check that the // session is not destroyed before we try doing anything with it // (like updating stats, sending pending data, etc). - int err = ngtcp2_conn_read_pkt( - *this, &path, - // TODO(@jasnell): ECN pkt_info blocked on libuv - nullptr, - vec.base, vec.len, uv_hrtime()); + int err = + ngtcp2_conn_read_pkt(*this, + &path, + // TODO(@jasnell): ECN pkt_info blocked on libuv + nullptr, + vec.base, + vec.len, + uv_hrtime()); switch (err) { case 0: { @@ -2477,10 +2478,8 @@ void Session::SelectPreferredAddress(PreferredAddress* preferredAddress) { auto ipv4 = preferredAddress->ipv4(); if (ipv4.has_value()) { if (ipv4->host[0] == '\0' || ipv4->port == 0) return; - CHECK(SocketAddress::New(AF_INET, - ipv4->host, - ipv4->port, - &impl_->remote_address_)); + CHECK(SocketAddress::New( + AF_INET, ipv4->host, ipv4->port, &impl_->remote_address_)); preferredAddress->Use(ipv4.value()); } break; @@ -2490,10 +2489,8 @@ void Session::SelectPreferredAddress(PreferredAddress* preferredAddress) { auto ipv6 = preferredAddress->ipv6(); if (ipv6.has_value()) { if (ipv6->host[0] == '\0' || ipv6->port == 0) return; - CHECK(SocketAddress::New(AF_INET6, - ipv6->host, - ipv6->port, - &impl_->remote_address_)); + CHECK(SocketAddress::New( + AF_INET6, ipv6->host, ipv6->port, &impl_->remote_address_)); preferredAddress->Use(ipv6.value()); } break; @@ -2767,16 +2764,14 @@ void Session::EmitNewToken(const uint8_t* token, size_t len) { CallbackScope cb_scope(this); Local argv[2]; - auto buf = Buffer::Copy( - env(), reinterpret_cast(token), len); + auto buf = Buffer::Copy(env(), reinterpret_cast(token), len); if (!buf.ToLocal(&argv[0])) return; argv[1] = SocketAddressBase::Create( - env(), - std::make_shared(remote_address()))->object(); - MakeCallback( - BindingData::Get(env()).session_new_token_callback(), - arraysize(argv), - argv); + env(), std::make_shared(remote_address())) + ->object(); + MakeCallback(BindingData::Get(env()).session_new_token_callback(), + arraysize(argv), + argv); } void Session::EmitStream(const BaseObjectWeakPtr& stream) { diff --git a/src/quic/sessionticket.cc b/src/quic/sessionticket.cc index 2fe44c1f56e48a..ac394fd572765b 100644 --- a/src/quic/sessionticket.cc +++ b/src/quic/sessionticket.cc @@ -1,15 +1,15 @@ #if HAVE_OPENSSL && HAVE_QUIC #include "guard.h" #ifndef OPENSSL_NO_QUIC -#include "sessionticket.h" -#include "tlscontext.h" -#include "session.h" -#include #include #include #include #include #include +#include +#include "session.h" +#include "sessionticket.h" +#include "tlscontext.h" namespace node { diff --git a/src/quic/streams.cc b/src/quic/streams.cc index 4b1536629580de..6edbb97d829f9c 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -83,9 +83,9 @@ namespace quic { V(ResetStream, resetStream, false) \ V(SetPriority, setPriority, false) \ V(GetPriority, getPriority, true) \ - V(GetReader, getReader, false) \ - V(InitStreamingSource, initStreamingSource, false) \ - V(Write, write, false) \ + V(GetReader, getReader, false) \ + V(InitStreamingSource, initStreamingSource, false) \ + V(Write, write, false) \ V(EndWrite, endWrite, false) // ============================================================================ @@ -149,10 +149,7 @@ namespace { // possible (e.g., SharedArrayBuffer-backed or non-detachable). // Returns nullptr on failure (error already thrown if allocation failed). std::unique_ptr CreateEntryFromBuffer( - Environment* env, - Local buffer, - size_t offset, - size_t length) { + Environment* env, Local buffer, size_t offset, size_t length) { if (length == 0) return nullptr; std::shared_ptr backing; if (buffer->IsDetachable()) { @@ -1111,8 +1108,8 @@ void Stream::set_outbound(std::shared_ptr source) { void Stream::InitStreaming() { auto env = this->env(); if (outbound_ != nullptr) { - return THROW_ERR_INVALID_STATE(env, - "Outbound data source is already initialized"); + return THROW_ERR_INVALID_STATE( + env, "Outbound data source is already initialized"); } if (!is_writable()) { return THROW_ERR_INVALID_STATE(env, "Stream is not writable"); @@ -1126,8 +1123,7 @@ void Stream::InitStreaming() { void Stream::WriteStreamData(const v8::FunctionCallbackInfo& args) { auto env = this->env(); if (outbound_ == nullptr || !outbound_->is_streaming()) { - return THROW_ERR_INVALID_STATE(env, - "Streaming source is not initialized"); + return THROW_ERR_INVALID_STATE(env, "Streaming source is not initialized"); } if (!is_writable()) { @@ -1142,8 +1138,8 @@ void Stream::WriteStreamData(const v8::FunctionCallbackInfo& args) { auto view = value.As(); auto length = view->ByteLength(); if (length == 0) return true; - auto entry = CreateEntryFromBuffer( - env, view->Buffer(), view->ByteOffset(), length); + auto entry = + CreateEntryFromBuffer(env, view->Buffer(), view->ByteOffset(), length); if (!entry) { return false; } @@ -1462,7 +1458,6 @@ void Stream::Schedule(Queue* queue) { if (outbound_ && stream_queue_.IsEmpty()) queue->PushBack(this); } - } // namespace quic } // namespace node diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index ce387bd656c07f..f70db3b3671726 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -89,7 +89,7 @@ void EnableTrace(Environment* env, BIOPointer* bio, SSL* ssl) { #endif } -template Opt::* member> +template Opt::*member> bool SetOption(Environment* env, Opt* options, const Local& object, @@ -419,16 +419,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { } if (SSL_CTX_set_max_early_data( - ctx.get(), - options_.enable_early_data ? UINT32_MAX : 0) != 1) { + ctx.get(), options_.enable_early_data ? UINT32_MAX : 0) != 1) { validation_error_ = "Failed to set max early data"; return {}; } // ngtcp2 handles replay protection at the QUIC layer, // so we disable OpenSSL's built-in anti-replay. SSL_CTX_set_options(ctx.get(), - (SSL_OP_ALL & - ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | + (SSL_OP_ALL & ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE | SSL_OP_NO_ANTI_REPLAY); @@ -453,7 +451,7 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { SessionTicket::GenerateCallback, SessionTicket::DecryptedCallback, nullptr), - 1); + 1); break; } case Side::CLIENT: { From cd391b5f11954dc6d62061a6152d855d09b7ba01 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 10 Jul 2025 17:45:41 -0700 Subject: [PATCH 4/7] test: wpt for Wasm jsapi including new ESM Integration tests PR-URL: https://github.com/nodejs/node/pull/59034 Reviewed-By: Chengzhong Wu --- test/common/wpt.js | 2 +- test/common/wpt/worker.js | 16 +- test/fixtures/wpt/README.md | 2 +- test/fixtures/wpt/versions.json | 2 +- test/fixtures/wpt/wasm/jsapi/WEB_FEATURES.yml | 4 + test/fixtures/wpt/wasm/jsapi/assertions.js | 5 + test/fixtures/wpt/wasm/jsapi/bad-imports.js | 1 + .../wasm/jsapi/constructor/WEB_FEATURES.yml | 8 + .../wpt/wasm/jsapi/constructor/compile.any.js | 2 +- .../instantiate-bad-imports.any.js | 2 +- .../wasm/jsapi/constructor/instantiate.any.js | 2 +- .../wasm/jsapi/constructor/multi-value.any.js | 2 +- .../wasm/jsapi/constructor/toStringTag.any.js | 2 +- .../wasm/jsapi/constructor/validate.any.js | 2 +- .../jsapi/esm-integration/WEB_FEATURES.yml | 9 + .../esm-integration/exports.tentative.any.js | 36 ++ ...bal-exports-live-bindings.tentative.any.js | 46 ++ .../global-exports.tentative.any.js | 111 +++++ .../js-wasm-cycle.tentative.any.js | 7 + .../mutable-global-sharing.tentative.any.js | 80 +++ .../namespace-instance.tentative.any.js | 56 +++ .../reserved-import-names.tentative.any.js | 41 ++ .../resolve-export.tentative.any.js | 9 + .../jsapi/esm-integration/resources/dep.wasm | Bin 0 -> 529 bytes .../esm-integration/resources/exports.wasm | Bin 0 -> 226 bytes .../esm-integration/resources/globals.js | 29 ++ .../esm-integration/resources/globals.wasm | Bin 0 -> 2973 bytes .../invalid-export-name-wasm-js.wasm | Bin 0 -> 64 bytes .../resources/invalid-export-name.wasm | Bin 0 -> 61 bytes .../resources/invalid-import-module.wasm | Bin 0 -> 94 bytes .../invalid-import-name-wasm-js.wasm | Bin 0 -> 94 bytes .../resources/invalid-import-name.wasm | Bin 0 -> 91 bytes .../resources/js-string-builtins.wasm | Bin 0 -> 401 bytes .../resources/js-wasm-cycle.js | 17 + .../resources/js-wasm-cycle.wasm | Bin 0 -> 101 bytes .../jsapi/esm-integration/resources/log.js | 1 + .../resources/mutable-global-export.wasm | Bin 0 -> 378 bytes .../resources/mutable-global-reexport.wasm | Bin 0 -> 428 bytes .../resources/resolve-export.js | 1 + .../resources/resolve-export.wasm | Bin 0 -> 8 bytes .../resources/source-phase-identity.js | 6 + .../resources/wasm-export-to-wasm.wasm | Bin 0 -> 45 bytes .../resources/wasm-import-from-wasm.wasm | Bin 0 -> 75 bytes ...rce-phase-string-builtins.tentative.any.js | 39 ++ .../source-phase.tentative.any.js | 49 ++ .../string-builtins.tentative.any.js | 12 + .../esm-integration/v128-tdz.tentative.any.js | 11 + .../wasm-import-wasm-export.tentative.any.js | 14 + .../wpt/wasm/jsapi/exception/WEB_FEATURES.yml | 3 + .../jsapi/exception/basic.tentative.any.js | 16 +- .../exception/constructor.tentative.any.js | 2 +- .../jsapi/exception/getArg.tentative.any.js | 2 +- .../jsapi/exception/identity.tentative.any.js | 2 +- .../wasm/jsapi/exception/is.tentative.any.js | 2 +- .../jsapi/exception/toString.tentative.any.js | 2 +- .../wasm/jsapi/function/call.tentative.any.js | 2 +- .../function/constructor.tentative.any.js | 2 +- .../jsapi/function/table.tentative.any.js | 2 +- .../wasm/jsapi/function/type.tentative.any.js | 2 +- .../wpt/wasm/jsapi/functions/WEB_FEATURES.yml | 3 + .../wpt/wasm/jsapi/gc/casts.tentative.any.js | 332 +++++++++++++ .../jsapi/gc/exported-object.tentative.any.js | 190 +++++++ .../wpt/wasm/jsapi/gc/i31.tentative.any.js | 98 ++++ .../wpt/wasm/jsapi/global/WEB_FEATURES.yml | 3 + .../wpt/wasm/jsapi/global/constructor.any.js | 2 +- .../wpt/wasm/jsapi/global/toString.any.js | 2 +- .../wasm/jsapi/global/type.tentative.any.js | 2 +- .../wasm/jsapi/global/value-get-set.any.js | 11 +- .../wpt/wasm/jsapi/global/valueOf.any.js | 2 +- .../fixtures/wpt/wasm/jsapi/idlharness.any.js | 1 + .../wpt/wasm/jsapi/instance/WEB_FEATURES.yml | 3 + .../instance/constructor-bad-imports.any.js | 2 +- .../jsapi/instance/constructor-caching.any.js | 2 +- .../wasm/jsapi/instance/constructor.any.js | 2 +- .../wpt/wasm/jsapi/instance/exports.any.js | 2 +- .../wpt/wasm/jsapi/instance/toString.any.js | 2 +- .../wpt/wasm/jsapi/instanceTestFactory.js | 8 +- test/fixtures/wpt/wasm/jsapi/interface.any.js | 2 +- .../wpt/wasm/jsapi/js-string/WEB_FEATURES.yml | 3 + .../wpt/wasm/jsapi/js-string/basic.any.js | 383 ++++++++++++++ .../wpt/wasm/jsapi/js-string/constants.any.js | 61 +++ .../wpt/wasm/jsapi/js-string/imports.any.js | 26 + .../wpt/wasm/jsapi/js-string/polyfill.js | 170 +++++++ test/fixtures/wpt/wasm/jsapi/jspi/README.txt | 3 + .../jsapi/jspi/js-promise-integration.any.js | 370 ++++++++++++++ .../wpt/wasm/jsapi/jspi/notraps.any.js | 82 +++ .../wpt/wasm/jsapi/jspi/rejects.any.js | 150 ++++++ .../wasm/jsapi/jspi/testharness-additions.js | 26 + .../wpt/wasm/jsapi/memory/WEB_FEATURES.yml | 12 + .../wpt/wasm/jsapi/memory/assertions.js | 2 + .../constructor-shared.tentative.any.js | 2 +- .../memory/constructor-types.tentative.any.js | 4 +- .../wpt/wasm/jsapi/memory/constructor.any.js | 2 +- .../wpt/wasm/jsapi/memory/grow.any.js | 2 +- .../to-fixed-length-buffer-shared.any.js | 17 + .../memory/to-fixed-length-buffer.any.js | 42 ++ .../memory/to-resizable-buffer-shared.any.js | 36 ++ .../jsapi/memory/to-resizable-buffer.any.js | 72 +++ .../wasm/jsapi/memory/type.tentative.any.js | 4 +- .../wpt/wasm/jsapi/module/WEB_FEATURES.yml | 3 + .../wpt/wasm/jsapi/module/constructor.any.js | 2 +- .../wasm/jsapi/module/customSections.any.js | 2 +- .../wpt/wasm/jsapi/module/exports.any.js | 8 +- .../wpt/wasm/jsapi/module/imports.any.js | 2 +- .../module/moduleSource.tentative.any.js | 35 ++ .../wpt/wasm/jsapi/module/toString.any.js | 2 +- .../fixtures/wpt/wasm/jsapi/prototypes.any.js | 2 +- .../wpt/wasm/jsapi/table/WEB_FEATURES.yml | 3 + .../wpt/wasm/jsapi/table/assertions.js | 2 + .../table/constructor-types.tentative.any.js | 4 +- .../wpt/wasm/jsapi/table/constructor.any.js | 7 +- .../wpt/wasm/jsapi/table/get-set.any.js | 2 +- .../fixtures/wpt/wasm/jsapi/table/grow.any.js | 2 +- .../wpt/wasm/jsapi/table/length.any.js | 2 +- .../wpt/wasm/jsapi/table/toString.any.js | 2 +- .../wasm/jsapi/table/type.tentative.any.js | 2 +- .../wpt/wasm/jsapi/tag/WEB_FEATURES.yml | 3 + .../jsapi/tag/constructor.tentative.any.js | 2 +- .../wasm/jsapi/tag/toString.tentative.any.js | 2 +- .../wpt/wasm/jsapi/tag/type.tentative.any.js | 2 +- .../wpt/wasm/jsapi/wasm-module-builder.js | 466 +++++++++++++++--- test/fixtures/wpt/wasm/resources/load_wasm.js | 12 + test/wpt/status/wasm/jsapi.json | 96 ++++ test/wpt/test-wasm-jsapi.mjs | 22 + 124 files changed, 3347 insertions(+), 141 deletions(-) create mode 100644 test/fixtures/wpt/wasm/jsapi/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/constructor/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/exports.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports-live-bindings.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/js-wasm-cycle.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/mutable-global-sharing.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/namespace-instance.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/reserved-import-names.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resolve-export.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/dep.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/exports.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/globals.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/globals.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-export-name-wasm-js.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-export-name.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-import-module.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-import-name-wasm-js.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-import-name.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-string-builtins.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-wasm-cycle.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-wasm-cycle.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/log.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/mutable-global-export.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/mutable-global-reexport.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/resolve-export.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/resolve-export.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/source-phase-identity.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/wasm-export-to-wasm.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/resources/wasm-import-from-wasm.wasm create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/source-phase-string-builtins.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/source-phase.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/string-builtins.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/v128-tdz.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/esm-integration/wasm-import-wasm-export.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/exception/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/functions/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/gc/casts.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/gc/exported-object.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/gc/i31.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/global/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/instance/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/js-string/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/js-string/basic.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/js-string/constants.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/js-string/imports.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/js-string/polyfill.js create mode 100644 test/fixtures/wpt/wasm/jsapi/jspi/README.txt create mode 100644 test/fixtures/wpt/wasm/jsapi/jspi/js-promise-integration.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/jspi/notraps.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/jspi/rejects.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/jspi/testharness-additions.js create mode 100644 test/fixtures/wpt/wasm/jsapi/memory/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer-shared.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer-shared.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/module/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/module/moduleSource.tentative.any.js create mode 100644 test/fixtures/wpt/wasm/jsapi/table/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/jsapi/tag/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/wasm/resources/load_wasm.js create mode 100644 test/wpt/status/wasm/jsapi.json create mode 100644 test/wpt/test-wasm-jsapi.mjs diff --git a/test/common/wpt.js b/test/common/wpt.js index 8a0e4bea2ec568..6f776351435f1a 100644 --- a/test/common/wpt.js +++ b/test/common/wpt.js @@ -922,7 +922,7 @@ class WPTRunner { console.log(test.stack); } const command = `${process.execPath} ${process.execArgv}` + - ` ${require.main.filename} '${spec.filename}${spec.variant}'`; + ` ${require.main?.filename} '${spec.filename}${spec.variant}'`; console.log(`Command: ${command}\n`); reportResult?.addSubtest(test.name, 'FAIL', test.message); diff --git a/test/common/wpt/worker.js b/test/common/wpt/worker.js index 855ec7e91c394b..30585ecbe57f73 100644 --- a/test/common/wpt/worker.js +++ b/test/common/wpt/worker.js @@ -1,6 +1,10 @@ 'use strict'; -const { runInNewContext, runInThisContext } = require('vm'); +const { + runInNewContext, + runInThisContext, + constants: { USE_MAIN_CONTEXT_DEFAULT_LOADER }, +} = require('vm'); const { setFlagsFromString } = require('v8'); const { parentPort, workerData } = require('worker_threads'); @@ -28,11 +32,14 @@ globalThis.fetch = function fetch(file) { }; if (workerData.initScript) { - runInThisContext(workerData.initScript); + runInThisContext(workerData.initScript, { + importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER, + }); } runInThisContext(workerData.harness.code, { filename: workerData.harness.filename, + importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER, }); // eslint-disable-next-line no-undef @@ -66,5 +73,8 @@ add_completion_callback((_, status) => { }); for (const scriptToRun of workerData.scriptsToRun) { - runInThisContext(scriptToRun.code, { filename: scriptToRun.filename }); + runInThisContext(scriptToRun.code, { + filename: scriptToRun.filename, + importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER, + }); } diff --git a/test/fixtures/wpt/README.md b/test/fixtures/wpt/README.md index e98a36e57c2c96..fea9ea89123875 100644 --- a/test/fixtures/wpt/README.md +++ b/test/fixtures/wpt/README.md @@ -31,7 +31,7 @@ Last update: - url: https://github.com/web-platform-tests/wpt/tree/fc3e651593/url - urlpattern: https://github.com/web-platform-tests/wpt/tree/a2e15ad405/urlpattern - user-timing: https://github.com/web-platform-tests/wpt/tree/5ae85bf826/user-timing -- wasm/jsapi: https://github.com/web-platform-tests/wpt/tree/cde25e7e3c/wasm/jsapi +- wasm/jsapi: https://github.com/web-platform-tests/wpt/tree/65a2134d50/wasm/jsapi - wasm/webapi: https://github.com/web-platform-tests/wpt/tree/fd1b23eeaa/wasm/webapi - web-locks: https://github.com/web-platform-tests/wpt/tree/10a122a6bc/web-locks - WebCryptoAPI: https://github.com/web-platform-tests/wpt/tree/2cb332d710/WebCryptoAPI diff --git a/test/fixtures/wpt/versions.json b/test/fixtures/wpt/versions.json index b9638973a2749c..b69eafd6741950 100644 --- a/test/fixtures/wpt/versions.json +++ b/test/fixtures/wpt/versions.json @@ -84,7 +84,7 @@ "path": "user-timing" }, "wasm/jsapi": { - "commit": "cde25e7e3c3b9d2280eb088a3fb9da988793d255", + "commit": "65a2134d50", "path": "wasm/jsapi" }, "wasm/webapi": { diff --git a/test/fixtures/wpt/wasm/jsapi/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/WEB_FEATURES.yml new file mode 100644 index 00000000000000..e69294f1e41249 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/WEB_FEATURES.yml @@ -0,0 +1,4 @@ +features: +- name: wasm + files: + - "*" diff --git a/test/fixtures/wpt/wasm/jsapi/assertions.js b/test/fixtures/wpt/wasm/jsapi/assertions.js index 162f5a9a6b8dcc..3b370190682a3a 100644 --- a/test/fixtures/wpt/wasm/jsapi/assertions.js +++ b/test/fixtures/wpt/wasm/jsapi/assertions.js @@ -6,6 +6,7 @@ function assert_function_name(fn, name, description) { assert_true(propdesc.configurable, "configurable", `${description} name should be configurable`); assert_equals(propdesc.value, name, `${description} name should be ${name}`); } +globalThis.assert_function_name = assert_function_name; function assert_function_length(fn, length, description) { const propdesc = Object.getOwnPropertyDescriptor(fn, "length"); @@ -15,6 +16,7 @@ function assert_function_length(fn, length, description) { assert_true(propdesc.configurable, "configurable", `${description} length should be configurable`); assert_equals(propdesc.value, length, `${description} length should be ${length}`); } +globalThis.assert_function_length = assert_function_length; function assert_exported_function(fn, { name, length }, description) { if (WebAssembly.Function === undefined) { @@ -28,6 +30,7 @@ function assert_exported_function(fn, { name, length }, description) { assert_function_name(fn, name, description); assert_function_length(fn, length, description); } +globalThis.assert_exported_function = assert_exported_function; function assert_Instance(instance, expected_exports) { assert_equals(Object.getPrototypeOf(instance), WebAssembly.Instance.prototype, @@ -77,6 +80,7 @@ function assert_Instance(instance, expected_exports) { } } } +globalThis.assert_Instance = assert_Instance; function assert_WebAssemblyInstantiatedSource(actual, expected_exports={}) { assert_equals(Object.getPrototypeOf(actual), Object.prototype, @@ -98,3 +102,4 @@ function assert_WebAssemblyInstantiatedSource(actual, expected_exports={}) { assert_true(instance.configurable, "instance: configurable"); assert_Instance(instance.value, expected_exports); } +globalThis.assert_WebAssemblyInstantiatedSource = assert_WebAssemblyInstantiatedSource; diff --git a/test/fixtures/wpt/wasm/jsapi/bad-imports.js b/test/fixtures/wpt/wasm/jsapi/bad-imports.js index 786fc650e326b6..c7c9c6b6cdd135 100644 --- a/test/fixtures/wpt/wasm/jsapi/bad-imports.js +++ b/test/fixtures/wpt/wasm/jsapi/bad-imports.js @@ -183,3 +183,4 @@ function test_bad_imports(t) { }); } } +globalThis.test_bad_imports = test_bad_imports; diff --git a/test/fixtures/wpt/wasm/jsapi/constructor/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/constructor/WEB_FEATURES.yml new file mode 100644 index 00000000000000..65154744f2a27a --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/constructor/WEB_FEATURES.yml @@ -0,0 +1,8 @@ +features: +- name: wasm + files: + - "*" + - "!multi-value.any.js" +- name: wasm-multi-value + files: + - "multi-value.any.js" diff --git a/test/fixtures/wpt/wasm/jsapi/constructor/compile.any.js b/test/fixtures/wpt/wasm/jsapi/constructor/compile.any.js index e94ce11717369f..f822aa30e69eb9 100644 --- a/test/fixtures/wpt/wasm/jsapi/constructor/compile.any.js +++ b/test/fixtures/wpt/wasm/jsapi/constructor/compile.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js function assert_Module(module) { diff --git a/test/fixtures/wpt/wasm/jsapi/constructor/instantiate-bad-imports.any.js b/test/fixtures/wpt/wasm/jsapi/constructor/instantiate-bad-imports.any.js index 30252bd6eeb3ab..e4926c8d70a86e 100644 --- a/test/fixtures/wpt/wasm/jsapi/constructor/instantiate-bad-imports.any.js +++ b/test/fixtures/wpt/wasm/jsapi/constructor/instantiate-bad-imports.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=/wasm/jsapi/bad-imports.js diff --git a/test/fixtures/wpt/wasm/jsapi/constructor/instantiate.any.js b/test/fixtures/wpt/wasm/jsapi/constructor/instantiate.any.js index 8152f3a56f3f43..34e005c4700932 100644 --- a/test/fixtures/wpt/wasm/jsapi/constructor/instantiate.any.js +++ b/test/fixtures/wpt/wasm/jsapi/constructor/instantiate.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/instanceTestFactory.js diff --git a/test/fixtures/wpt/wasm/jsapi/constructor/multi-value.any.js b/test/fixtures/wpt/wasm/jsapi/constructor/multi-value.any.js index 4b06d1da3c49b9..8786f9b953ba18 100644 --- a/test/fixtures/wpt/wasm/jsapi/constructor/multi-value.any.js +++ b/test/fixtures/wpt/wasm/jsapi/constructor/multi-value.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=/wasm/jsapi/assertions.js diff --git a/test/fixtures/wpt/wasm/jsapi/constructor/toStringTag.any.js b/test/fixtures/wpt/wasm/jsapi/constructor/toStringTag.any.js index c6d2cdaf662e8b..5fae8304f8c0f6 100644 --- a/test/fixtures/wpt/wasm/jsapi/constructor/toStringTag.any.js +++ b/test/fixtures/wpt/wasm/jsapi/constructor/toStringTag.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm "use strict"; // https://webidl.spec.whatwg.org/#es-namespaces diff --git a/test/fixtures/wpt/wasm/jsapi/constructor/validate.any.js b/test/fixtures/wpt/wasm/jsapi/constructor/validate.any.js index 8b4f4582ab2987..fce43d1e175f48 100644 --- a/test/fixtures/wpt/wasm/jsapi/constructor/validate.any.js +++ b/test/fixtures/wpt/wasm/jsapi/constructor/validate.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js let emptyModuleBinary; diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/esm-integration/WEB_FEATURES.yml new file mode 100644 index 00000000000000..1f5269eb29b01f --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/WEB_FEATURES.yml @@ -0,0 +1,9 @@ +features: +- name: wasm-mutable-globals + files: + - "global-exports-live-bindings.tentative.any.js" + - "mutable-global-sharing.tentative.any.js" +- name: wasm-string-builtins + files: + - "source-phase-string-builtins.tentative.any.js" + - "string-builtins.tentative.any.js" diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/exports.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/exports.tentative.any.js new file mode 100644 index 00000000000000..9feaa283aaed26 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/exports.tentative.any.js @@ -0,0 +1,36 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +"use strict"; + +promise_test(async () => { + const mod = await import("./resources/exports.wasm"); + + assert_array_equals(Object.getOwnPropertyNames(mod).sort(), [ + "a\u200Bb\u0300c", + "func", + "glob", + "mem", + "tab", + "value with spaces", + "🎯test-func!", + ]); + assert_true(mod.func instanceof Function); + assert_true(mod.mem instanceof WebAssembly.Memory); + assert_true(mod.tab instanceof WebAssembly.Table); + + assert_false(mod.glob instanceof WebAssembly.Global); + assert_equals(typeof mod.glob, "number"); + + assert_throws_js(TypeError, () => { + mod.func = 2; + }); + + assert_equals(typeof mod["value with spaces"], "number"); + assert_equals(mod["value with spaces"], 123); + + assert_true(mod["🎯test-func!"] instanceof Function); + assert_equals(mod["🎯test-func!"](), 456); + + assert_equals(typeof mod["a\u200Bb\u0300c"], "number"); + assert_equals(mod["a\u200Bb\u0300c"], 789); +}, "Exported names from a WebAssembly module"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports-live-bindings.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports-live-bindings.tentative.any.js new file mode 100644 index 00000000000000..d80c30943c04c7 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports-live-bindings.tentative.any.js @@ -0,0 +1,46 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const wasmExports = await import("./resources/globals.wasm"); + + wasmExports.setLocalMutI32(555); + assert_equals(wasmExports.getLocalMutI32(), 555); + assert_equals(wasmExports.localMutI32, 555); + + wasmExports.setLocalMutI64(444n); + assert_equals(wasmExports.getLocalMutI64(), 444n); + assert_equals(wasmExports.localMutI64, 444n); + + wasmExports.setLocalMutF32(3.33); + assert_equals(Math.round(wasmExports.getLocalMutF32() * 100) / 100, 3.33); + assert_equals(Math.round(wasmExports.localMutF32 * 100) / 100, 3.33); + + wasmExports.setLocalMutF64(2.22); + assert_equals(wasmExports.getLocalMutF64(), 2.22); + assert_equals(wasmExports.localMutF64, 2.22); + + const anotherTestObj = { another: "test object" }; + wasmExports.setLocalMutExternref(anotherTestObj); + assert_equals(wasmExports.getLocalMutExternref(), anotherTestObj); + assert_equals(wasmExports.localMutExternref, anotherTestObj); +}, "Local mutable global exports should be live bindings"); + +promise_test(async () => { + const wasmExports = await import("./resources/globals.wasm"); + + wasmExports.setDepMutI32(3001); + assert_equals(wasmExports.getDepMutI32(), 3001); + assert_equals(wasmExports.depMutI32, 3001); + + wasmExports.setDepMutI64(30000000001n); + assert_equals(wasmExports.getDepMutI64(), 30000000001n); + assert_equals(wasmExports.depMutI64, 30000000001n); + + wasmExports.setDepMutF32(30.01); + assert_equals(Math.round(wasmExports.getDepMutF32() * 100) / 100, 30.01); + assert_equals(Math.round(wasmExports.depMutF32 * 100) / 100, 30.01); + + wasmExports.setDepMutF64(300.0001); + assert_equals(wasmExports.getDepMutF64(), 300.0001); + assert_equals(wasmExports.depMutF64, 300.0001); +}, "Dep module mutable global exports should be live bindings"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports.tentative.any.js new file mode 100644 index 00000000000000..51ebcaf95a2ad9 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/global-exports.tentative.any.js @@ -0,0 +1,111 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const wasmModule = await import("./resources/globals.wasm"); + + assert_equals(wasmModule.importedI32, 42); + assert_equals(wasmModule.importedI64, 9223372036854775807n); + assert_equals(Math.round(wasmModule.importedF32 * 100000) / 100000, 3.14159); + assert_equals(wasmModule.importedF64, 3.141592653589793); + assert_not_equals(wasmModule.importedExternref, null); + assert_equals(wasmModule.importedNullExternref, null); +}, "WebAssembly module global values should be unwrapped when importing in ESM integration"); + +promise_test(async () => { + const wasmModule = await import("./resources/globals.wasm"); + + assert_equals(wasmModule.importedMutI32, 100); + assert_equals(wasmModule.importedMutI64, 200n); + assert_equals( + Math.round(wasmModule.importedMutF32 * 100000) / 100000, + 2.71828 + ); + assert_equals(wasmModule.importedMutF64, 2.718281828459045); + assert_not_equals(wasmModule.importedMutExternref, null); + assert_equals(wasmModule.importedMutExternref.mutable, "global"); +}, "WebAssembly mutable global values should be unwrapped when importing in ESM integration"); + +promise_test(async () => { + const wasmModule = await import("./resources/globals.wasm"); + + assert_equals(wasmModule["🚀localI32"], 42); + assert_equals(wasmModule.localMutI32, 100); + assert_equals(wasmModule.localI64, 9223372036854775807n); + assert_equals(wasmModule.localMutI64, 200n); + assert_equals(Math.round(wasmModule.localF32 * 100000) / 100000, 3.14159); + assert_equals(Math.round(wasmModule.localMutF32 * 100000) / 100000, 2.71828); + assert_equals(wasmModule.localF64, 2.718281828459045); + assert_equals(wasmModule.localMutF64, 3.141592653589793); +}, "WebAssembly local global values should be unwrapped when exporting in ESM integration"); + +promise_test(async () => { + const wasmModule = await import("./resources/globals.wasm"); + + assert_equals(wasmModule.depI32, 1001); + assert_equals(wasmModule.depMutI32, 2001); + assert_equals(wasmModule.depI64, 10000000001n); + assert_equals(wasmModule.depMutI64, 20000000001n); + assert_equals(Math.round(wasmModule.depF32 * 100) / 100, 10.01); + assert_equals(Math.round(wasmModule.depMutF32 * 100) / 100, 20.01); + assert_equals(wasmModule.depF64, 100.0001); + assert_equals(wasmModule.depMutF64, 200.0001); +}, "WebAssembly module globals from imported WebAssembly modules should be unwrapped"); + +promise_test(async () => { + const wasmModule = await import("./resources/globals.wasm"); + + assert_equals(wasmModule.importedI32, 42); + assert_equals(wasmModule.importedMutI32, 100); + assert_equals(wasmModule.importedI64, 9223372036854775807n); + assert_equals(wasmModule.importedMutI64, 200n); + assert_equals(Math.round(wasmModule.importedF32 * 100000) / 100000, 3.14159); + assert_equals( + Math.round(wasmModule.importedMutF32 * 100000) / 100000, + 2.71828 + ); + assert_equals(wasmModule.importedF64, 3.141592653589793); + assert_equals(wasmModule.importedMutF64, 2.718281828459045); + assert_equals(wasmModule.importedExternref !== null, true); + assert_equals(wasmModule.importedMutExternref !== null, true); + assert_equals(wasmModule.importedNullExternref, null); + + assert_equals(wasmModule["🚀localI32"], 42); + assert_equals(wasmModule.localMutI32, 100); + assert_equals(wasmModule.localI64, 9223372036854775807n); + assert_equals(wasmModule.localMutI64, 200n); + assert_equals(Math.round(wasmModule.localF32 * 100000) / 100000, 3.14159); + assert_equals(Math.round(wasmModule.localMutF32 * 100000) / 100000, 2.71828); + assert_equals(wasmModule.localF64, 2.718281828459045); + assert_equals(wasmModule.localMutF64, 3.141592653589793); + + assert_equals(wasmModule.getImportedMutI32(), 100); + assert_equals(wasmModule.getImportedMutI64(), 200n); + assert_equals( + Math.round(wasmModule.getImportedMutF32() * 100000) / 100000, + 2.71828 + ); + assert_equals(wasmModule.getImportedMutF64(), 2.718281828459045); + assert_equals(wasmModule.getImportedMutExternref() !== null, true); + + assert_equals(wasmModule.getLocalMutI32(), 100); + assert_equals(wasmModule.getLocalMutI64(), 200n); + assert_equals( + Math.round(wasmModule.getLocalMutF32() * 100000) / 100000, + 2.71828 + ); + assert_equals(wasmModule.getLocalMutF64(), 3.141592653589793); + assert_equals(wasmModule.getLocalMutExternref(), null); + + assert_equals(wasmModule.depI32, 1001); + assert_equals(wasmModule.depMutI32, 2001); + assert_equals(wasmModule.getDepMutI32(), 2001); + assert_equals(wasmModule.depI64, 10000000001n); + assert_equals(wasmModule.depMutI64, 20000000001n); + assert_equals(wasmModule.getDepMutI64(), 20000000001n); + assert_equals(Math.round(wasmModule.depF32 * 100) / 100, 10.01); + assert_equals(Math.round(wasmModule.depMutF32 * 100) / 100, 20.01); + assert_equals(Math.round(wasmModule.getDepMutF32() * 100) / 100, 20.01); + assert_equals(wasmModule.depF64, 100.0001); + assert_equals(wasmModule.depMutF64, 200.0001); + assert_equals(wasmModule.getDepMutF64(), 200.0001); +}, "WebAssembly should properly handle all global types"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/js-wasm-cycle.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/js-wasm-cycle.tentative.any.js new file mode 100644 index 00000000000000..ce71a33a24b043 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/js-wasm-cycle.tentative.any.js @@ -0,0 +1,7 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const { f } = await import("./resources/js-wasm-cycle.js"); + + assert_equals(f(), 24); +}, "Check bindings in JavaScript and WebAssembly cycle (JS higher)"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/mutable-global-sharing.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/mutable-global-sharing.tentative.any.js new file mode 100644 index 00000000000000..d76c69ad5d2333 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/mutable-global-sharing.tentative.any.js @@ -0,0 +1,80 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const exporterModule = await import("./resources/mutable-global-export.wasm"); + const reexporterModule = await import( + "./resources/mutable-global-reexport.wasm" + ); + + assert_equals(exporterModule.mutableValue, 100); + assert_equals(reexporterModule.reexportedMutableValue, 100); +}, "WebAssembly modules should export shared mutable globals with correct initial values"); + +promise_test(async () => { + const exporterModule = await import("./resources/mutable-global-export.wasm"); + const reexporterModule = await import( + "./resources/mutable-global-reexport.wasm" + ); + + exporterModule.setGlobal(500); + + assert_equals(exporterModule.getGlobal(), 500, "exporter should see 500"); + assert_equals(reexporterModule.getImportedGlobal(), 500); + + reexporterModule.setImportedGlobal(600); + + assert_equals(exporterModule.getGlobal(), 600); + assert_equals(reexporterModule.getImportedGlobal(), 600); + + exporterModule.setGlobal(700); + + assert_equals(exporterModule.getGlobal(), 700); + assert_equals(reexporterModule.getImportedGlobal(), 700); +}, "Wasm-to-Wasm mutable global sharing is live"); + +promise_test(async () => { + const module1 = await import("./resources/mutable-global-export.wasm"); + const module2 = await import("./resources/mutable-global-export.wasm"); + + assert_equals(module1, module2); + + module1.setGlobal(800); + assert_equals(module1.getGlobal(), 800, "module1 should see its own change"); + assert_equals(module2.getGlobal(), 800); +}, "Multiple JavaScript imports return the same WebAssembly module instance"); + +promise_test(async () => { + const exporterModule = await import("./resources/mutable-global-export.wasm"); + const reexporterModule = await import( + "./resources/mutable-global-reexport.wasm" + ); + + assert_equals(exporterModule.getV128Lane(0), 1); + assert_equals(exporterModule.getV128Lane(1), 2); + assert_equals(exporterModule.getV128Lane(2), 3); + assert_equals(exporterModule.getV128Lane(3), 4); + + assert_equals(reexporterModule.getImportedV128Lane(0), 1); + assert_equals(reexporterModule.getImportedV128Lane(1), 2); + assert_equals(reexporterModule.getImportedV128Lane(2), 3); + assert_equals(reexporterModule.getImportedV128Lane(3), 4); +}, "v128 globals should work correctly in WebAssembly-to-WebAssembly imports"); + +promise_test(async () => { + const exporterModule = await import("./resources/mutable-global-export.wasm"); + const reexporterModule = await import( + "./resources/mutable-global-reexport.wasm" + ); + + exporterModule.setV128Global(10, 20, 30, 40); + + assert_equals(exporterModule.getV128Lane(0), 10); + assert_equals(exporterModule.getV128Lane(1), 20); + assert_equals(exporterModule.getV128Lane(2), 30); + assert_equals(exporterModule.getV128Lane(3), 40); + + assert_equals(reexporterModule.getImportedV128Lane(0), 10); + assert_equals(reexporterModule.getImportedV128Lane(1), 20); + assert_equals(reexporterModule.getImportedV128Lane(2), 30); + assert_equals(reexporterModule.getImportedV128Lane(3), 40); +}, "v128 global mutations should work correctly between WebAssembly modules"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/namespace-instance.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/namespace-instance.tentative.any.js new file mode 100644 index 00000000000000..d50b5c6cb80edf --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/namespace-instance.tentative.any.js @@ -0,0 +1,56 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const wasmNamespace = await import("./resources/mutable-global-export.wasm"); + const instance = WebAssembly.namespaceInstance(wasmNamespace); + + const wasmNamespace2 = await import("./resources/mutable-global-export.wasm"); + const instance2 = WebAssembly.namespaceInstance(wasmNamespace2); + assert_equals(instance, instance2); + + assert_true(instance instanceof WebAssembly.Instance); + + wasmNamespace.setGlobal(999); + assert_equals(instance.exports.getGlobal(), 999); + + instance.exports.setGlobal(888); + assert_equals(wasmNamespace.getGlobal(), 888); +}, "WebAssembly.namespaceInstance() should return the underlying instance with shared state"); + +promise_test(async () => { + assert_throws_js(TypeError, () => WebAssembly.namespaceInstance({})); + assert_throws_js(TypeError, () => WebAssembly.namespaceInstance(null)); + assert_throws_js(TypeError, () => WebAssembly.namespaceInstance(undefined)); + assert_throws_js(TypeError, () => WebAssembly.namespaceInstance(42)); + assert_throws_js(TypeError, () => + WebAssembly.namespaceInstance("not a namespace") + ); + assert_throws_js(TypeError, () => WebAssembly.namespaceInstance([])); + assert_throws_js(TypeError, () => + WebAssembly.namespaceInstance(function () {}) + ); + + const jsModule = await import("./resources/globals.js"); + assert_throws_js(TypeError, () => WebAssembly.namespaceInstance(jsModule)); +}, "WebAssembly.namespaceInstance() should throw TypeError for non-WebAssembly namespaces"); + +promise_test(async () => { + const exportsModule = await import("./resources/exports.wasm"); + const globalsModule = await import("./resources/globals.wasm"); + + const exportsInstance = WebAssembly.namespaceInstance(exportsModule); + const globalsInstance = WebAssembly.namespaceInstance(globalsModule); + + assert_not_equals(exportsInstance, globalsInstance); + assert_true(exportsInstance.exports.func instanceof Function); + assert_true(globalsInstance.exports.getLocalMutI32 instanceof Function); + + globalsModule.setLocalMutI32(12345); + assert_equals(globalsInstance.exports.getLocalMutI32(), 12345); + + globalsInstance.exports.setLocalMutI32(54321); + assert_equals(globalsModule.getLocalMutI32(), 54321); + + const exportsInstance2 = WebAssembly.namespaceInstance(exportsModule); + assert_equals(exportsInstance, exportsInstance2); +}, "WebAssembly.namespaceInstance() should work correctly with multiple modules"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/reserved-import-names.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/reserved-import-names.tentative.any.js new file mode 100644 index 00000000000000..aa17735b0206ff --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/reserved-import-names.tentative.any.js @@ -0,0 +1,41 @@ +// Test that wasm: and wasm-js: reserved cases should cause WebAssembly.LinkError + +promise_test(async (t) => { + await promise_rejects_js( + t, + WebAssembly.LinkError, + import("./resources/invalid-import-name.wasm") + ); +}, "wasm: reserved import names should cause WebAssembly.LinkError"); + +promise_test(async (t) => { + await promise_rejects_js( + t, + WebAssembly.LinkError, + import("./resources/invalid-import-name-wasm-js.wasm") + ); +}, "wasm-js: reserved import names should cause WebAssembly.LinkError"); + +promise_test(async (t) => { + await promise_rejects_js( + t, + WebAssembly.LinkError, + import("./resources/invalid-export-name.wasm") + ); +}, "wasm: reserved export names should cause WebAssembly.LinkError"); + +promise_test(async (t) => { + await promise_rejects_js( + t, + WebAssembly.LinkError, + import("./resources/invalid-export-name-wasm-js.wasm") + ); +}, "wasm-js: reserved export names should cause WebAssembly.LinkError"); + +promise_test(async (t) => { + await promise_rejects_js( + t, + WebAssembly.LinkError, + import("./resources/invalid-import-module.wasm") + ); +}, "wasm-js: reserved module names should cause WebAssembly.LinkError"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resolve-export.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/resolve-export.tentative.any.js new file mode 100644 index 00000000000000..86325d11b0bad5 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/resolve-export.tentative.any.js @@ -0,0 +1,9 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async (t) => { + await promise_rejects_js( + t, + SyntaxError, + import("./resources/resolve-export.js") + ); +}, "ResolveExport on invalid re-export from WebAssembly"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/dep.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/dep.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ad9abfaa66af53b33aca06422ad2850020731a9c GIT binary patch literal 529 zcmZQbEY4+QU|?XBWJ_RRtOw#cAg%@C8X(RG;%c@Uo_Yqym+aj2jE)!ix$7958c(cW zpvqmx=+rpj{((~r+_enO-!v2)xoa7nt3?(#a@R1pY|_hH%*YVwz+J=W5-Gm@A_GIF z19v{dg?#ROMj%nm@Rx@RXsrkk%K@lkBt|^M#!=Ar&g4t z7UdPCra?{S5Jrd~)Nyi^85&tYm2mNaIS5&9hRuvDd5O8H?5i1h7!Y1yMD+m^k_S-T g&w_A1E2`_+5RPX@bvp;T(>bxZoD0d}+-U9w0Ac`}+5i9m literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/exports.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/exports.wasm new file mode 100644 index 0000000000000000000000000000000000000000..7382eff0356128f58ee446e15be51f662e406590 GIT binary patch literal 226 zcmXAjF>b;@5Jms&tZjmoNQMiTf(9u)Ep@7#z^u*MfV_a@H7TKwlrA+#;2J471>ykd zY1lQ*`_gFUzkzBD0ib~9paZi2q@-}vC&3=t;z|qs<$~q zmhGkhlD(>1oqnzCGHrIM&<)eu|LNy!bz|TC&Ir^~*W-77ITkGXuOh(ghS>3sV|>bu uGMa|sSx9|tw(C{q4}*|8W$4pA5RIMH%PH|xb>^*EXB984EvK1YRQdt#qBtG^ literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/globals.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/globals.js new file mode 100644 index 00000000000000..fabf23faf38ae3 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/globals.js @@ -0,0 +1,29 @@ +const i32_value = 42; +export { i32_value as "🚀i32_value" }; +export const i64_value = 9223372036854775807n; +export const f32_value = 3.14159; +export const f64_value = 3.141592653589793; + +export const i32_mut_value = new WebAssembly.Global( + { value: "i32", mutable: true }, + 100 +); +export const i64_mut_value = new WebAssembly.Global( + { value: "i64", mutable: true }, + 200n +); +export const f32_mut_value = new WebAssembly.Global( + { value: "f32", mutable: true }, + 2.71828 +); +export const f64_mut_value = new WebAssembly.Global( + { value: "f64", mutable: true }, + 2.718281828459045 +); + +export const externref_value = { hello: "world" }; +export const externref_mut_value = new WebAssembly.Global( + { value: "externref", mutable: true }, + { mutable: "global" } +); +export const null_externref_value = null; diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/globals.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/globals.wasm new file mode 100644 index 0000000000000000000000000000000000000000..c5e6dd9f1ded20ddec7f1a5fa84b27bd063d0827 GIT binary patch literal 2973 zcmZ`*JCEB&5Z)CjQ4}ANDC%)Kotz+P62NDGB-jNw+m`?b#4d~gDeq*PXDE=4#+@!n zp-1`@>C@y_q)wYE@GnS}!j<9DK#F8WOYTcL0Xw_%eLM55hO?r`+NIi; zWuqDOu*Bd50=By){1p00;Eft|5gydBVNo2~-GU(Cl_0Er1o2SK0>pJ|3j*6NL0AVa z>=Y86Cd(|4(8bF?%U?`pDBX3o%R;K0`?pV0=$vGO(len z+NF}f#_d))3IIETP|6`vcU*u)RSqN?q)|BH>ZvcRDu7C)5FB zpsAv<(AZ9md_+E_Y%g|amPrFV2-pi!+UmPjz2U*qbi=#m6#?Ibml&@qQNjDNQ z$`>+@`pWej?75wCBgc`{$B;{I<2V{AO?|Ydr>(z?d&DhY4(C&7h8Fkc@DB`-ip0au zs>Y{@Dfr7cs;WF7HHn9ZuJLJ754d7hzR2tz$M6&!Z}d5?{xfncc+KExT?ZG%(LIKy zi7(JJX>L(CS&O6Kv>BQvfk4xw<6%0kAGXWv9O~NJw+d?aJhm*<`z4bm1DQ#agMtCs ap~QfDSTbocl9@C)YO>^9c-#?c9Q+^eI*}y+ literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-export-name-wasm-js.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-export-name-wasm-js.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a6b9a7f7c5ad57d8347003d77940f6ec99dadf26 GIT binary patch literal 64 zcmZQbEY4+QU|?WmWlUgTtY>CoWMCI&t+>OW#*M7=47TYFmSOkvM@MmaWn9- SCoWMCIy{zCFW$NFfeejF|sf?YH>60vE(J@ PrZTcKGO(1S7MB13J$4H? literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-import-module.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-import-module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ead151ac0c84ad5a2504c1f7752a08e14b989a0e GIT binary patch literal 94 zcmZQbEY4+QU|?WmWlUgTtY?y71GvMW#*M7=47U@l%y7yFfcGPF*2}oFhY2Y iTx^Ui3<3{zCFW$NFfcGPF*2}oKx7#h ix!4$47z7x&8Dv@V5_3}-#h4g)p-Me-3-XIfAPNDJ%oP;? literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-import-name.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/invalid-import-name.wasm new file mode 100644 index 0000000000000000000000000000000000000000..3c631418294584fecbe13fc122c28dbab5670525 GIT binary patch literal 91 zcmZQbEY4+QU|?WmWlUgTtY;EsWGP84F5xK$id$vol_ln6rZ6xtGchu-b3mjR7`fOO fSr`NuxEW+w@)C1X8O4|wc%e!?a|`l|N+1dWJZlsM literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-string-builtins.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-string-builtins.wasm new file mode 100644 index 0000000000000000000000000000000000000000..4c4ff4df7f62b145aaf258e6cfb4775a19a9b437 GIT binary patch literal 401 zcmZ8cK~BRk5S+C)PKni0i4U}A!~u@*1#UbbwSpC>niy(V9Qz)Ai5RFujaT6oXwiGGd+vdJkw{yfo)H+#aB<`6BGHNRU Pj^&Zk`=tJ%I!pcliXL*D literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-wasm-cycle.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-wasm-cycle.js new file mode 100644 index 00000000000000..9f14dc04bde41f --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-wasm-cycle.js @@ -0,0 +1,17 @@ +function f() { + return 42; +} +export { f }; + +import { mem, tab, glob, func } from "./js-wasm-cycle.wasm"; +assert_false(glob instanceof WebAssembly.Global, "imported global should be unwrapped in ESM integration"); +assert_equals(glob, 1, "unwrapped global should have direct value"); +assert_true(mem instanceof WebAssembly.Memory); +assert_true(tab instanceof WebAssembly.Table); +assert_true(func instanceof Function); + +f = () => { + return 24; +}; + +assert_equals(func(), 42); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-wasm-cycle.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/js-wasm-cycle.wasm new file mode 100644 index 0000000000000000000000000000000000000000..77a3b86ab67528e404eef4aae4a7c76511b1f863 GIT binary patch literal 101 zcmWN{K@LSQ6a~r77$x)=z630v?nfBvd^j^*`v u>2JRb)f_njVhC04aETDkT0Ur)rG-iuf`J@e|BY$cdV!AT>Op6z$dSE}6%fk+ literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/log.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/log.js new file mode 100644 index 00000000000000..0c4f5ed519b0fd --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/log.js @@ -0,0 +1 @@ +export function logExec() { log.push("executed"); } diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/mutable-global-export.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/mutable-global-export.wasm new file mode 100644 index 0000000000000000000000000000000000000000..89c478b19151d0abdc0c6fdb24935df8a2d7d1e7 GIT binary patch literal 378 zcmY+Ay-veG5QJxTFNvH>3JNMLWGQ)pKtt;SXy}kqA;pel(bx$h&JR$ShvhX+Vl7Mp zD~)vD?G&?vd{Yqs&t1j_8Q5g5v4J5Q?Kx1iA4|jZ3;u&))Y`?QCTH@4$Ns3=*7>q5 z=6Sg-6vN%@>gM+FvVN_V(Wa>H%k?rZf#~`8=Ggw{=IeIiZP)TczAB)@C)Y=UDZIl3 zK1PagmLSAP7-A+8Vo5Z_I;EePF~yy0CJYzo8L$JkARS1r-K)33C*3Nq3SK*$^6?Z; t{-@Kw;bf{HqE)e9?Am+9Iy#VV!gu9f30K+)gG<4OpwR^I>s literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/mutable-global-reexport.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/mutable-global-reexport.wasm new file mode 100644 index 0000000000000000000000000000000000000000..b06f94a75d3c48e753a3f5860643af3e8820a660 GIT binary patch literal 428 zcmaJ-!Ab)$6rA^xwb)g$D81-Gtk+iR$%7ysY!UQY@YJUCQH1QSwA)tjkS`ZZy1@!7 z=90`z=4CQ4%Zvcfk(!Xh1myfqj$iq_cb%tU0|2zZ2$TQ- literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/wasm-import-from-wasm.wasm b/test/fixtures/wpt/wasm/jsapi/esm-integration/resources/wasm-import-from-wasm.wasm new file mode 100644 index 0000000000000000000000000000000000000000..652ff143100f832bf3e4e16c4427e17e2a651148 GIT binary patch literal 75 zcmZQbEY4+QU|?WmVN76PU{Yd~($g;ois`0S6yz6`=$7Q`g1CAhf;lHYoq>UwiIIVw Xn~@#Jaji&AW? { + const wasmModuleSource = await import.source("./resources/js-string-builtins.wasm"); + + assert_true(wasmModuleSource instanceof WebAssembly.Module); + + const instance = new WebAssembly.Instance(wasmModuleSource, {}); + + assert_equals(instance.exports.getLength("hello"), 5); + assert_equals( + instance.exports.concatStrings("hello", " world"), + "hello world" + ); + assert_equals(instance.exports.compareStrings("test", "test"), 1); + assert_equals(instance.exports.compareStrings("test", "different"), 0); + assert_equals(instance.exports.testString("hello"), 1); + assert_equals(instance.exports.testString(42), 0); +}, "String builtins should be supported in source phase imports"); + +promise_test(async () => { + const wasmModuleSource = await import.source("./resources/js-string-builtins.wasm"); + + const exports = WebAssembly.Module.exports(wasmModuleSource); + const exportNames = exports.map((exp) => exp.name); + + assert_true(exportNames.includes("getLength")); + assert_true(exportNames.includes("concatStrings")); + assert_true(exportNames.includes("compareStrings")); + assert_true(exportNames.includes("testString")); +}, "Source phase import should properly expose string builtin exports"); + +promise_test(async () => { + const wasmModuleSource = await import.source("./resources/js-string-builtins.wasm"); + + const imports = WebAssembly.Module.imports(wasmModuleSource); + + assert_equals(imports.length, 0); +}, "Source phase import should handle string builtin import reflection correctly"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/source-phase.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/source-phase.tentative.any.js new file mode 100644 index 00000000000000..ad45391f7f9c9f --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/source-phase.tentative.any.js @@ -0,0 +1,49 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const exportedNamesSource = await import.source("./resources/exports.wasm"); + + assert_true(exportedNamesSource instanceof WebAssembly.Module); + const AbstractModuleSource = Object.getPrototypeOf(WebAssembly.Module); + assert_equals(AbstractModuleSource.name, "AbstractModuleSource"); + assert_true(exportedNamesSource instanceof AbstractModuleSource); + + assert_array_equals( + WebAssembly.Module.exports(exportedNamesSource) + .map(({ name }) => name) + .sort(), + [ + "a\u200Bb\u0300c", + "func", + "glob", + "mem", + "tab", + "value with spaces", + "🎯test-func!", + ] + ); + + const wasmImportFromWasmSource = await import.source( + "./resources/wasm-import-from-wasm.wasm" + ); + + assert_true(wasmImportFromWasmSource instanceof WebAssembly.Module); + + let logged = false; + const instance = await WebAssembly.instantiate(wasmImportFromWasmSource, { + "./wasm-export-to-wasm.wasm": { + log() { + logged = true; + }, + }, + }); + instance.exports.logExec(); + assert_true(logged, "WebAssembly instance should execute imported function"); +}, "Source phase imports"); + +promise_test(async () => { + const { mod1, mod2, mod3, mod4 } = await import('./resources/source-phase-identity.js'); + + assert_equals(mod1, mod2); + assert_equals(mod3, mod4); +}, "Source phase identities"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/string-builtins.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/string-builtins.tentative.any.js new file mode 100644 index 00000000000000..bac8fd92727437 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/string-builtins.tentative.any.js @@ -0,0 +1,12 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const wasmModule = await import("./resources/js-string-builtins.wasm"); + + assert_equals(wasmModule.getLength("hello"), 5); + assert_equals(wasmModule.concatStrings("hello", " world"), "hello world"); + assert_equals(wasmModule.compareStrings("test", "test"), 1); + assert_equals(wasmModule.compareStrings("test", "different"), 0); + assert_equals(wasmModule.testString("hello"), 1); + assert_equals(wasmModule.testString(42), 0); +}, "String builtins should be supported in imports in ESM integration"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/v128-tdz.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/v128-tdz.tentative.any.js new file mode 100644 index 00000000000000..5d11e590148851 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/v128-tdz.tentative.any.js @@ -0,0 +1,11 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + const exporterModule = await import("./resources/mutable-global-export.wasm"); + const reexporterModule = await import( + "./resources/mutable-global-reexport.wasm" + ); + + assert_throws_js(ReferenceError, () => exporterModule.v128Export); + assert_throws_js(ReferenceError, () => reexporterModule.reexportedV128Export); +}, "v128 global exports should cause TDZ errors"); diff --git a/test/fixtures/wpt/wasm/jsapi/esm-integration/wasm-import-wasm-export.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/esm-integration/wasm-import-wasm-export.tentative.any.js new file mode 100644 index 00000000000000..2c1a1446ee4987 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/esm-integration/wasm-import-wasm-export.tentative.any.js @@ -0,0 +1,14 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm + +promise_test(async () => { + globalThis.log = []; + + const { logExec } = await import("./resources/wasm-import-from-wasm.wasm"); + logExec(); + + assert_equals(globalThis.log.length, 1, "log should have one entry"); + assert_equals(globalThis.log[0], "executed"); + + // Clean up + delete globalThis.log; +}, "Check import and export between WebAssembly modules"); diff --git a/test/fixtures/wpt/wasm/jsapi/exception/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/exception/WEB_FEATURES.yml new file mode 100644 index 00000000000000..ffbe372e8d6523 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/exception/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm-exception-handling + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/exception/basic.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/exception/basic.tentative.any.js index acf644f904f53d..1b690ec463b36a 100644 --- a/test/fixtures/wpt/wasm/jsapi/exception/basic.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/exception/basic.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,worker,jsshell +// META: global=window,worker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js function assert_throws_wasm(fn, message) { @@ -7,12 +7,16 @@ function assert_throws_wasm(fn, message) { assert_not_reached(`expected to throw with ${message}`); } catch (e) { assert_true(e instanceof WebAssembly.Exception, `Error should be a WebAssembly.Exception with ${message}`); + // According to the spec discussion, the current `WebAssembly.Exception` does not have `[[ErrorData]]` semantically. + // - https://github.com/WebAssembly/spec/issues/1914 + // - https://webassembly.github.io/spec/js-api/#exceptions + // - https://tc39.es/ecma262/multipage/fundamental-objects.html#sec-properties-of-error-instances + assert_false(Error.isError(e), `Error.isError(WebAssembly.Exception) should be false due to lacking [[ErrorData]]`); } } promise_test(async () => { - const kWasmAnyRef = 0x6f; - const kSig_v_r = makeSig([kWasmAnyRef], []); + const kSig_v_r = makeSig([kWasmExternRef], []); const builder = new WasmModuleBuilder(); const tagIndex = builder.addTag(kSig_v_r); builder.addFunction("throw_param", kSig_v_r) @@ -48,7 +52,7 @@ promise_test(async () => { const tagIndex = builder.addTag(kSig_v_a); builder.addFunction("throw_null", kSig_v_v) .addBody([ - kExprRefNull, kWasmAnyFunc, + kExprRefNull, kAnyFuncCode, kExprThrow, tagIndex, ]) .exportFunc(); @@ -82,7 +86,7 @@ promise_test(async () => { kExprCatch, tagIndex, kExprReturn, kExprEnd, - kExprRefNull, kWasmAnyRef, + kExprRefNull, kExternRefCode, ]) .exportFunc(); @@ -106,7 +110,7 @@ promise_test(async () => { kExprCatchAll, kExprRethrow, 0x00, kExprEnd, - kExprRefNull, kWasmAnyRef, + kExprRefNull, kExternRefCode, ]) .exportFunc(); diff --git a/test/fixtures/wpt/wasm/jsapi/exception/constructor.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/exception/constructor.tentative.any.js index 7ad08e1883ba13..a46d1816c351d2 100644 --- a/test/fixtures/wpt/wasm/jsapi/exception/constructor.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/exception/constructor.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/exception/getArg.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/exception/getArg.tentative.any.js index f0a568a857f0e7..87719c7ebdfb85 100644 --- a/test/fixtures/wpt/wasm/jsapi/exception/getArg.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/exception/getArg.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/memory/assertions.js test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/exception/identity.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/exception/identity.tentative.any.js index 65787c107e3c29..2675668ec73a95 100644 --- a/test/fixtures/wpt/wasm/jsapi/exception/identity.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/exception/identity.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/wasm-module-builder.js diff --git a/test/fixtures/wpt/wasm/jsapi/exception/is.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/exception/is.tentative.any.js index e28a88a3c5fdcf..840d00bf0d47a9 100644 --- a/test/fixtures/wpt/wasm/jsapi/exception/is.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/exception/is.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/memory/assertions.js test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/exception/toString.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/exception/toString.tentative.any.js index 00e801a6fc1a4a..6885cf0deb6918 100644 --- a/test/fixtures/wpt/wasm/jsapi/exception/toString.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/exception/toString.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm test(() => { const argument = { parameters: [] }; diff --git a/test/fixtures/wpt/wasm/jsapi/function/call.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/function/call.tentative.any.js index 626cd13c9f0095..2e63d5fa103fd1 100644 --- a/test/fixtures/wpt/wasm/jsapi/function/call.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/function/call.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function addxy(x, y) { diff --git a/test/fixtures/wpt/wasm/jsapi/function/constructor.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/function/constructor.tentative.any.js index 636aeca4dc1fa0..fc92fcfaf0cf49 100644 --- a/test/fixtures/wpt/wasm/jsapi/function/constructor.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/function/constructor.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function addxy(x, y) { diff --git a/test/fixtures/wpt/wasm/jsapi/function/table.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/function/table.tentative.any.js index d7d0d86e3b6a88..f0dd6ea6f8589a 100644 --- a/test/fixtures/wpt/wasm/jsapi/function/table.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/function/table.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function testfunc(n) {} diff --git a/test/fixtures/wpt/wasm/jsapi/function/type.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/function/type.tentative.any.js index e01a23a9e4339e..72a7f1bfbe5e0a 100644 --- a/test/fixtures/wpt/wasm/jsapi/function/type.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/function/type.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function addNumbers(x, y, z) { diff --git a/test/fixtures/wpt/wasm/jsapi/functions/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/functions/WEB_FEATURES.yml new file mode 100644 index 00000000000000..d8e24fc0e8f503 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/functions/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/gc/casts.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/gc/casts.tentative.any.js new file mode 100644 index 00000000000000..cce06224fd40c7 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/gc/casts.tentative.any.js @@ -0,0 +1,332 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +let exports = {}; +setup(() => { + const builder = new WasmModuleBuilder(); + const structIndex = builder.addStruct([makeField(kWasmI32, true)]); + const arrayIndex = builder.addArray(kWasmI32, true); + const structIndex2 = builder.addStruct([makeField(kWasmF32, true)]); + const arrayIndex2 = builder.addArray(kWasmF32, true); + const funcIndex = builder.addType({ params: [], results: [] }); + const funcIndex2 = builder.addType({ params: [], results: [kWasmI32] }); + + const argFunctions = [ + { name: "any", code: kWasmAnyRef }, + { name: "eq", code: kWasmEqRef }, + { name: "struct", code: kWasmStructRef }, + { name: "array", code: kWasmArrayRef }, + { name: "i31", code: kWasmI31Ref }, + { name: "func", code: kWasmFuncRef }, + { name: "extern", code: kWasmExternRef }, + { name: "none", code: kWasmNullRef }, + { name: "nofunc", code: kWasmNullFuncRef }, + { name: "noextern", code: kWasmNullExternRef }, + { name: "concreteStruct", code: structIndex }, + { name: "concreteArray", code: arrayIndex }, + { name: "concreteFunc", code: funcIndex }, + ]; + + for (const desc of argFunctions) { + builder + .addFunction(desc.name + "Arg", makeSig_v_x(wasmRefType(desc.code))) + .addBody([]) + .exportFunc(); + + builder + .addFunction(desc.name + "NullableArg", makeSig_v_x(wasmRefNullType(desc.code))) + .addBody([]) + .exportFunc(); + } + + builder + .addFunction("makeStruct", makeSig_r_v(wasmRefType(structIndex))) + .addBody([...wasmI32Const(42), + ...GCInstr(kExprStructNew), structIndex]) + .exportFunc(); + + builder + .addFunction("makeArray", makeSig_r_v(wasmRefType(arrayIndex))) + .addBody([...wasmI32Const(5), ...wasmI32Const(42), + ...GCInstr(kExprArrayNew), arrayIndex]) + .exportFunc(); + + builder + .addFunction("makeStruct2", makeSig_r_v(wasmRefType(structIndex2))) + .addBody([...wasmF32Const(42), + ...GCInstr(kExprStructNew), structIndex2]) + .exportFunc(); + + builder + .addFunction("makeArray2", makeSig_r_v(wasmRefType(arrayIndex2))) + .addBody([...wasmF32Const(42), ...wasmI32Const(5), + ...GCInstr(kExprArrayNew), arrayIndex2]) + .exportFunc(); + + builder + .addFunction("testFunc", funcIndex) + .addBody([]) + .exportFunc(); + + builder + .addFunction("testFunc2", funcIndex2) + .addBody([...wasmI32Const(42)]) + .exportFunc(); + + const buffer = builder.toBuffer(); + const module = new WebAssembly.Module(buffer); + const instance = new WebAssembly.Instance(module, {}); + exports = instance.exports; +}); + +test(() => { + exports.anyArg(exports.makeStruct()); + exports.anyArg(exports.makeArray()); + exports.anyArg(42); + exports.anyArg(42n); + exports.anyArg("foo"); + exports.anyArg({}); + exports.anyArg(() => {}); + exports.anyArg(exports.testFunc); + assert_throws_js(TypeError, () => exports.anyArg(null)); + + exports.anyNullableArg(null); + exports.anyNullableArg(exports.makeStruct()); + exports.anyNullableArg(exports.makeArray()); + exports.anyNullableArg(42); + exports.anyNullableArg(42n); + exports.anyNullableArg("foo"); + exports.anyNullableArg({}); + exports.anyNullableArg(() => {}); + exports.anyNullableArg(exports.testFunc); +}, "anyref casts"); + +test(() => { + exports.eqArg(exports.makeStruct()); + exports.eqArg(exports.makeArray()); + exports.eqArg(42); + assert_throws_js(TypeError, () => exports.eqArg(42n)); + assert_throws_js(TypeError, () => exports.eqArg("foo")); + assert_throws_js(TypeError, () => exports.eqArg({})); + assert_throws_js(TypeError, () => exports.eqArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.eqArg(() => {})); + assert_throws_js(TypeError, () => exports.eqArg(null)); + + exports.eqNullableArg(null); + exports.eqNullableArg(exports.makeStruct()); + exports.eqNullableArg(exports.makeArray()); + exports.eqNullableArg(42); + assert_throws_js(TypeError, () => exports.eqNullableArg(42n)); + assert_throws_js(TypeError, () => exports.eqNullableArg("foo")); + assert_throws_js(TypeError, () => exports.eqNullableArg({})); + assert_throws_js(TypeError, () => exports.eqNullableArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.eqNullableArg(() => {})); +}, "eqref casts"); + +test(() => { + exports.structArg(exports.makeStruct()); + assert_throws_js(TypeError, () => exports.structArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.structArg(42)); + assert_throws_js(TypeError, () => exports.structArg(42n)); + assert_throws_js(TypeError, () => exports.structArg("foo")); + assert_throws_js(TypeError, () => exports.structArg({})); + assert_throws_js(TypeError, () => exports.structArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.structArg(() => {})); + assert_throws_js(TypeError, () => exports.structArg(null)); + + exports.structNullableArg(null); + exports.structNullableArg(exports.makeStruct()); + assert_throws_js(TypeError, () => exports.structNullableArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.structNullableArg(42)); + assert_throws_js(TypeError, () => exports.structNullableArg(42n)); + assert_throws_js(TypeError, () => exports.structNullableArg("foo")); + assert_throws_js(TypeError, () => exports.structNullableArg({})); + assert_throws_js(TypeError, () => exports.structNullableArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.structNullableArg(() => {})); +}, "structref casts"); + +test(() => { + exports.arrayArg(exports.makeArray()); + assert_throws_js(TypeError, () => exports.arrayArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.arrayArg(42)); + assert_throws_js(TypeError, () => exports.arrayArg(42n)); + assert_throws_js(TypeError, () => exports.arrayArg("foo")); + assert_throws_js(TypeError, () => exports.arrayArg({})); + assert_throws_js(TypeError, () => exports.arrayArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.arrayArg(() => {})); + assert_throws_js(TypeError, () => exports.arrayArg(null)); + + exports.arrayNullableArg(null); + exports.arrayNullableArg(exports.makeArray()); + assert_throws_js(TypeError, () => exports.arrayNullableArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.arrayNullableArg(42)); + assert_throws_js(TypeError, () => exports.arrayNullableArg(42n)); + assert_throws_js(TypeError, () => exports.arrayNullableArg("foo")); + assert_throws_js(TypeError, () => exports.arrayNullableArg({})); + assert_throws_js(TypeError, () => exports.arrayNullableArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.arrayNullableArg(() => {})); +}, "arrayref casts"); + +test(() => { + exports.i31Arg(42); + assert_throws_js(TypeError, () => exports.i31Arg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.i31Arg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.i31Arg(42n)); + assert_throws_js(TypeError, () => exports.i31Arg("foo")); + assert_throws_js(TypeError, () => exports.i31Arg({})); + assert_throws_js(TypeError, () => exports.i31Arg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.i31Arg(() => {})); + assert_throws_js(TypeError, () => exports.i31Arg(null)); + + exports.i31NullableArg(null); + exports.i31NullableArg(42); + assert_throws_js(TypeError, () => exports.i31NullableArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.i31NullableArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.i31NullableArg(42n)); + assert_throws_js(TypeError, () => exports.i31NullableArg("foo")); + assert_throws_js(TypeError, () => exports.i31NullableArg({})); + assert_throws_js(TypeError, () => exports.i31NullableArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.i31NullableArg(() => {})); +}, "i31ref casts"); + +test(() => { + exports.funcArg(exports.testFunc); + assert_throws_js(TypeError, () => exports.funcArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.funcArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.funcArg(42)); + assert_throws_js(TypeError, () => exports.funcArg(42n)); + assert_throws_js(TypeError, () => exports.funcArg("foo")); + assert_throws_js(TypeError, () => exports.funcArg({})); + assert_throws_js(TypeError, () => exports.funcArg(() => {})); + assert_throws_js(TypeError, () => exports.funcArg(null)); + + exports.funcNullableArg(null); + exports.funcNullableArg(exports.testFunc); + assert_throws_js(TypeError, () => exports.funcNullableArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.funcNullableArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.funcNullableArg(42)); + assert_throws_js(TypeError, () => exports.funcNullableArg(42n)); + assert_throws_js(TypeError, () => exports.funcNullableArg("foo")); + assert_throws_js(TypeError, () => exports.funcNullableArg({})); + assert_throws_js(TypeError, () => exports.funcNullableArg(() => {})); +}, "funcref casts"); + +test(() => { + exports.externArg(exports.makeArray()); + exports.externArg(exports.makeStruct()); + exports.externArg(42); + exports.externArg(42n); + exports.externArg("foo"); + exports.externArg({}); + exports.externArg(exports.testFunc); + exports.externArg(() => {}); + assert_throws_js(TypeError, () => exports.externArg(null)); + + exports.externNullableArg(null); + exports.externNullableArg(exports.makeArray()); + exports.externNullableArg(exports.makeStruct()); + exports.externNullableArg(42); + exports.externNullableArg(42n); + exports.externNullableArg("foo"); + exports.externNullableArg({}); + exports.externNullableArg(exports.testFunc); + exports.externNullableArg(() => {}); +}, "externref casts"); + +test(() => { + for (const nullfunc of [exports.noneArg, exports.nofuncArg, exports.noexternArg]) { + assert_throws_js(TypeError, () => nullfunc(exports.makeStruct())); + assert_throws_js(TypeError, () => nullfunc(exports.makeArray())); + assert_throws_js(TypeError, () => nullfunc(42)); + assert_throws_js(TypeError, () => nullfunc(42n)); + assert_throws_js(TypeError, () => nullfunc("foo")); + assert_throws_js(TypeError, () => nullfunc({})); + assert_throws_js(TypeError, () => nullfunc(exports.testFunc)); + assert_throws_js(TypeError, () => nullfunc(() => {})); + assert_throws_js(TypeError, () => nullfunc(null)); + } + + for (const nullfunc of [exports.noneNullableArg, exports.nofuncNullableArg, exports.noexternNullableArg]) { + nullfunc(null); + assert_throws_js(TypeError, () => nullfunc(exports.makeStruct())); + assert_throws_js(TypeError, () => nullfunc(exports.makeArray())); + assert_throws_js(TypeError, () => nullfunc(42)); + assert_throws_js(TypeError, () => nullfunc(42n)); + assert_throws_js(TypeError, () => nullfunc("foo")); + assert_throws_js(TypeError, () => nullfunc({})); + assert_throws_js(TypeError, () => nullfunc(exports.testFunc)); + assert_throws_js(TypeError, () => nullfunc(() => {})); + } +}, "null casts"); + +test(() => { + exports.concreteStructArg(exports.makeStruct()); + assert_throws_js(TypeError, () => exports.concreteStructArg(exports.makeStruct2())); + assert_throws_js(TypeError, () => exports.concreteStructArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.concreteStructArg(42)); + assert_throws_js(TypeError, () => exports.concreteStructArg(42n)); + assert_throws_js(TypeError, () => exports.concreteStructArg("foo")); + assert_throws_js(TypeError, () => exports.concreteStructArg({})); + assert_throws_js(TypeError, () => exports.concreteStructArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.concreteStructArg(() => {})); + assert_throws_js(TypeError, () => exports.concreteStructArg(null)); + + exports.concreteStructNullableArg(null); + exports.concreteStructNullableArg(exports.makeStruct()); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg(exports.makeStruct2())); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg(42)); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg(42n)); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg("foo")); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg({})); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.concreteStructNullableArg(() => {})); +}, "concrete struct casts"); + +test(() => { + exports.concreteArrayArg(exports.makeArray()); + assert_throws_js(TypeError, () => exports.concreteArrayArg(exports.makeArray2())); + assert_throws_js(TypeError, () => exports.concreteArrayArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.concreteArrayArg(42)); + assert_throws_js(TypeError, () => exports.concreteArrayArg(42n)); + assert_throws_js(TypeError, () => exports.concreteArrayArg("foo")); + assert_throws_js(TypeError, () => exports.concreteArrayArg({})); + assert_throws_js(TypeError, () => exports.concreteArrayArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.concreteArrayArg(() => {})); + assert_throws_js(TypeError, () => exports.concreteArrayArg(null)); + + exports.concreteArrayNullableArg(null); + exports.concreteArrayNullableArg(exports.makeArray()); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg(exports.makeArray2())); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg(42)); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg(42n)); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg("foo")); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg({})); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg(exports.testFunc)); + assert_throws_js(TypeError, () => exports.concreteArrayNullableArg(() => {})); +}, "concrete array casts"); + +test(() => { + exports.concreteFuncArg(exports.testFunc); + assert_throws_js(TypeError, () => exports.concreteFuncArg(exports.testFunc2)); + assert_throws_js(TypeError, () => exports.concreteFuncArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.concreteFuncArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.concreteFuncArg(42)); + assert_throws_js(TypeError, () => exports.concreteFuncArg(42n)); + assert_throws_js(TypeError, () => exports.concreteFuncArg("foo")); + assert_throws_js(TypeError, () => exports.concreteFuncArg({})); + assert_throws_js(TypeError, () => exports.concreteFuncArg(() => {})); + assert_throws_js(TypeError, () => exports.concreteFuncArg(null)); + + exports.concreteFuncNullableArg(null); + exports.concreteFuncNullableArg(exports.testFunc); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg(exports.testFunc2)); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg(exports.makeArray())); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg(exports.makeStruct())); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg(42)); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg(42n)); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg("foo")); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg({})); + assert_throws_js(TypeError, () => exports.concreteFuncNullableArg(() => {})); +}, "concrete func casts"); diff --git a/test/fixtures/wpt/wasm/jsapi/gc/exported-object.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/gc/exported-object.tentative.any.js new file mode 100644 index 00000000000000..b572f140067fda --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/gc/exported-object.tentative.any.js @@ -0,0 +1,190 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +let functions = {}; +setup(() => { + const builder = new WasmModuleBuilder(); + + const structIndex = builder.addStruct([makeField(kWasmI32, true)]); + const arrayIndex = builder.addArray(kWasmI32, true); + const structRef = wasmRefType(structIndex); + const arrayRef = wasmRefType(arrayIndex); + + builder + .addFunction("makeStruct", makeSig_r_v(structRef)) + .addBody([...wasmI32Const(42), + ...GCInstr(kExprStructNew), structIndex]) + .exportFunc(); + + builder + .addFunction("makeArray", makeSig_r_v(arrayRef)) + .addBody([...wasmI32Const(5), ...wasmI32Const(42), + ...GCInstr(kExprArrayNew), arrayIndex]) + .exportFunc(); + + const buffer = builder.toBuffer(); + const module = new WebAssembly.Module(buffer); + const instance = new WebAssembly.Instance(module, {}); + functions = instance.exports; +}); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_equals(struct.foo, undefined); + assert_equals(struct[0], undefined); + assert_equals(array.foo, undefined); + assert_equals(array[0], undefined); +}, "property access"); + +test(() => { + "use strict"; + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => { struct.foo = 5; }); + assert_throws_js(TypeError, () => { array.foo = 5; }); + assert_throws_js(TypeError, () => { struct[0] = 5; }); + assert_throws_js(TypeError, () => { array[0] = 5; }); +}, "property assignment (strict mode)"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => { struct.foo = 5; }); + assert_throws_js(TypeError, () => { array.foo = 5; }); + assert_throws_js(TypeError, () => { struct[0] = 5; }); + assert_throws_js(TypeError, () => { array[0] = 5; }); +}, "property assignment (non-strict mode)"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_equals(Object.getOwnPropertyNames(struct).length, 0); + assert_equals(Object.getOwnPropertyNames(array).length, 0); +}, "ownPropertyNames"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => Object.defineProperty(struct, "foo", { value: 1 })); + assert_throws_js(TypeError, () => Object.defineProperty(array, "foo", { value: 1 })); +}, "defineProperty"); + +test(() => { + "use strict"; + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => delete struct.foo); + assert_throws_js(TypeError, () => delete struct[0]); + assert_throws_js(TypeError, () => delete array.foo); + assert_throws_js(TypeError, () => delete array[0]); +}, "delete (strict mode)"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => delete struct.foo); + assert_throws_js(TypeError, () => delete struct[0]); + assert_throws_js(TypeError, () => delete array.foo); + assert_throws_js(TypeError, () => delete array[0]); +}, "delete (non-strict mode)"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_equals(Object.getPrototypeOf(struct), null); + assert_equals(Object.getPrototypeOf(array), null); +}, "getPrototypeOf"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => Object.setPrototypeOf(struct, {})); + assert_throws_js(TypeError, () => Object.setPrototypeOf(array, {})); +}, "setPrototypeOf"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_false(Object.isExtensible(struct)); + assert_false(Object.isExtensible(array)); +}, "isExtensible"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => Object.preventExtensions(struct)); + assert_throws_js(TypeError, () => Object.preventExtensions(array)); +}, "preventExtensions"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => Object.seal(struct)); + assert_throws_js(TypeError, () => Object.seal(array)); +}, "sealing"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_equals(typeof struct, "object"); + assert_equals(typeof array, "object"); +}, "typeof"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => struct.toString()); + assert_equals(Object.prototype.toString.call(struct), "[object Object]"); + assert_throws_js(TypeError, () => array.toString()); + assert_equals(Object.prototype.toString.call(array), "[object Object]"); +}, "toString"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + assert_throws_js(TypeError, () => struct.valueOf()); + assert_equals(Object.prototype.valueOf.call(struct), struct); + assert_throws_js(TypeError, () => array.valueOf()); + assert_equals(Object.prototype.valueOf.call(array), array); +}, "valueOf"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + const map = new Map(); + map.set(struct, "struct"); + map.set(array, "array"); + assert_equals(map.get(struct), "struct"); + assert_equals(map.get(array), "array"); +}, "GC objects as map keys"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + const set = new Set(); + set.add(struct); + set.add(array); + assert_true(set.has(struct)); + assert_true(set.has(array)); +}, "GC objects as set element"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + const map = new WeakMap(); + map.set(struct, "struct"); + map.set(array, "array"); + assert_equals(map.get(struct), "struct"); + assert_equals(map.get(array), "array"); +}, "GC objects as weak map keys"); + +test(() => { + const struct = functions.makeStruct(); + const array = functions.makeArray(); + const set = new WeakSet(); + set.add(struct); + set.add(array); + assert_true(set.has(struct)); + assert_true(set.has(array)); +}, "GC objects as weak set element"); diff --git a/test/fixtures/wpt/wasm/jsapi/gc/i31.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/gc/i31.tentative.any.js new file mode 100644 index 00000000000000..17fd82440cc8b2 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/gc/i31.tentative.any.js @@ -0,0 +1,98 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +let exports = {}; +setup(() => { + const builder = new WasmModuleBuilder(); + const i31Ref = wasmRefType(kWasmI31Ref); + const i31NullableRef = wasmRefNullType(kWasmI31Ref); + const anyRef = wasmRefType(kWasmAnyRef); + + builder + .addFunction("makeI31", makeSig_r_x(i31Ref, kWasmI32)) + .addBody([kExprLocalGet, 0, + ...GCInstr(kExprI31New)]) + .exportFunc(); + + builder + .addFunction("castI31", makeSig_r_x(kWasmI32, anyRef)) + .addBody([kExprLocalGet, 0, + ...GCInstr(kExprRefCast), kI31RefCode, + ...GCInstr(kExprI31GetU)]) + .exportFunc(); + + builder + .addFunction("getI31", makeSig_r_x(kWasmI32, i31Ref)) + .addBody([kExprLocalGet, 0, + ...GCInstr(kExprI31GetS)]) + .exportFunc(); + + builder + .addFunction("argI31", makeSig_v_x(i31NullableRef)) + .addBody([]) + .exportFunc(); + + builder + .addGlobal(i31NullableRef, true, [...wasmI32Const(0), ...GCInstr(kExprI31New)]) + builder + .addExportOfKind("i31Global", kExternalGlobal, 0); + + builder + .addTable(i31NullableRef, 10) + builder + .addExportOfKind("i31Table", kExternalTable, 0); + + const buffer = builder.toBuffer(); + const module = new WebAssembly.Module(buffer); + const instance = new WebAssembly.Instance(module, {}); + exports = instance.exports; +}); + +test(() => { + assert_equals(exports.makeI31(42), 42); + assert_equals(exports.makeI31(2 ** 30 - 1), 2 ** 30 - 1); + assert_equals(exports.makeI31(2 ** 30), -(2 ** 30)); + assert_equals(exports.makeI31(-(2 ** 30)), -(2 ** 30)); + assert_equals(exports.makeI31(2 ** 31 - 1), -1); + assert_equals(exports.makeI31(2 ** 31), 0); +}, "i31ref conversion to Number"); + +test(() => { + assert_equals(exports.getI31(exports.makeI31(42)), 42); + assert_equals(exports.getI31(42), 42); + assert_equals(exports.getI31(2.0 ** 30 - 1), 2 ** 30 - 1); + assert_equals(exports.getI31(-(2 ** 30)), -(2 ** 30)); +}, "Number conversion to i31ref"); + +test(() => { + exports.argI31(null); + assert_throws_js(TypeError, () => exports.argI31(2 ** 30)); + assert_throws_js(TypeError, () => exports.argI31(-(2 ** 30) - 1)); + assert_throws_js(TypeError, () => exports.argI31(2n)); + assert_throws_js(TypeError, () => exports.argI31(() => 3)); + assert_throws_js(TypeError, () => exports.argI31(exports.getI31)); +}, "Check i31ref argument type"); + +test(() => { + assert_equals(exports.castI31(42), 42); + assert_equals(exports.castI31(2 ** 30 - 1), 2 ** 30 - 1); + assert_throws_js(WebAssembly.RuntimeError, () => { exports.castI31(2 ** 30); }); + assert_throws_js(WebAssembly.RuntimeError, () => { exports.castI31(-(2 ** 30) - 1); }); + assert_throws_js(WebAssembly.RuntimeError, () => { exports.castI31(2 ** 32); }); +}, "Numbers in i31 range are i31ref, not hostref"); + +test(() => { + assert_equals(exports.i31Global.value, 0); + exports.i31Global.value = 42; + assert_throws_js(TypeError, () => exports.i31Global.value = 2 ** 30); + assert_throws_js(TypeError, () => exports.i31Global.value = -(2 ** 30) - 1); + assert_equals(exports.i31Global.value, 42); +}, "i31ref global"); + +test(() => { + assert_equals(exports.i31Table.get(0), null); + exports.i31Table.set(0, 42); + assert_throws_js(TypeError, () => exports.i31Table.set(0, 2 ** 30)); + assert_throws_js(TypeError, () => exports.i31Table.set(0, -(2 ** 30) - 1)); + assert_equals(exports.i31Table.get(0), 42); +}, "i31ref table"); diff --git a/test/fixtures/wpt/wasm/jsapi/global/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/global/WEB_FEATURES.yml new file mode 100644 index 00000000000000..d8e24fc0e8f503 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/global/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/global/constructor.any.js b/test/fixtures/wpt/wasm/jsapi/global/constructor.any.js index dade7b1f55a433..f83f77a5c3ecf3 100644 --- a/test/fixtures/wpt/wasm/jsapi/global/constructor.any.js +++ b/test/fixtures/wpt/wasm/jsapi/global/constructor.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function assert_Global(actual, expected) { diff --git a/test/fixtures/wpt/wasm/jsapi/global/toString.any.js b/test/fixtures/wpt/wasm/jsapi/global/toString.any.js index 359c4273b5bd78..b308498982ee5d 100644 --- a/test/fixtures/wpt/wasm/jsapi/global/toString.any.js +++ b/test/fixtures/wpt/wasm/jsapi/global/toString.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm test(() => { const argument = { "value": "i32" }; diff --git a/test/fixtures/wpt/wasm/jsapi/global/type.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/global/type.tentative.any.js index 95adc2af0f6813..78d612529dd3f3 100644 --- a/test/fixtures/wpt/wasm/jsapi/global/type.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/global/type.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function assert_type(argument) { diff --git a/test/fixtures/wpt/wasm/jsapi/global/value-get-set.any.js b/test/fixtures/wpt/wasm/jsapi/global/value-get-set.any.js index f95b7ca9e3f0d5..ee593619b96e8f 100644 --- a/test/fixtures/wpt/wasm/jsapi/global/value-get-set.any.js +++ b/test/fixtures/wpt/wasm/jsapi/global/value-get-set.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm test(() => { const thisValues = [ @@ -131,7 +131,14 @@ test(() => { const setter = desc.set; assert_equals(typeof setter, "function"); - assert_throws_js(TypeError, () => setter.call(global)); + assert_equals(global.value, 0); + + assert_equals(setter.call(global, undefined), undefined); + assert_equals(global.value, 0); + + // Should behave as if 'undefined' was passed as the argument. + assert_equals(setter.call(global), undefined); + assert_equals(global.value, 0); }, "Calling setter without argument"); test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/global/valueOf.any.js b/test/fixtures/wpt/wasm/jsapi/global/valueOf.any.js index 0695a5a61fbc6e..5bcb1718258988 100644 --- a/test/fixtures/wpt/wasm/jsapi/global/valueOf.any.js +++ b/test/fixtures/wpt/wasm/jsapi/global/valueOf.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm test(() => { const argument = { "value": "i32" }; diff --git a/test/fixtures/wpt/wasm/jsapi/idlharness.any.js b/test/fixtures/wpt/wasm/jsapi/idlharness.any.js index 98713d4bf6e43a..6478f857c7ae11 100644 --- a/test/fixtures/wpt/wasm/jsapi/idlharness.any.js +++ b/test/fixtures/wpt/wasm/jsapi/idlharness.any.js @@ -1,6 +1,7 @@ // META: script=/resources/WebIDLParser.js // META: script=/resources/idlharness.js // META: script=../resources/load_wasm.js +// META: global=window,dedicatedworker,shadowrealm-in-window 'use strict'; diff --git a/test/fixtures/wpt/wasm/jsapi/instance/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/instance/WEB_FEATURES.yml new file mode 100644 index 00000000000000..d8e24fc0e8f503 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/instance/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/instance/constructor-bad-imports.any.js b/test/fixtures/wpt/wasm/jsapi/instance/constructor-bad-imports.any.js index e4a5abb8eb2169..1ef4f8423de857 100644 --- a/test/fixtures/wpt/wasm/jsapi/instance/constructor-bad-imports.any.js +++ b/test/fixtures/wpt/wasm/jsapi/instance/constructor-bad-imports.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=/wasm/jsapi/bad-imports.js diff --git a/test/fixtures/wpt/wasm/jsapi/instance/constructor-caching.any.js b/test/fixtures/wpt/wasm/jsapi/instance/constructor-caching.any.js index 1aa4739b6294d0..f969364d93f462 100644 --- a/test/fixtures/wpt/wasm/jsapi/instance/constructor-caching.any.js +++ b/test/fixtures/wpt/wasm/jsapi/instance/constructor-caching.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js function getExports() { diff --git a/test/fixtures/wpt/wasm/jsapi/instance/constructor.any.js b/test/fixtures/wpt/wasm/jsapi/instance/constructor.any.js index 26390ebd2cdb2e..24bf97356c83a7 100644 --- a/test/fixtures/wpt/wasm/jsapi/instance/constructor.any.js +++ b/test/fixtures/wpt/wasm/jsapi/instance/constructor.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/instanceTestFactory.js diff --git a/test/fixtures/wpt/wasm/jsapi/instance/exports.any.js b/test/fixtures/wpt/wasm/jsapi/instance/exports.any.js index 6dcfbcee950d87..f7244923d83c51 100644 --- a/test/fixtures/wpt/wasm/jsapi/instance/exports.any.js +++ b/test/fixtures/wpt/wasm/jsapi/instance/exports.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js let emptyModuleBinary; diff --git a/test/fixtures/wpt/wasm/jsapi/instance/toString.any.js b/test/fixtures/wpt/wasm/jsapi/instance/toString.any.js index 547a9ca8295f5b..d77037d65b9c25 100644 --- a/test/fixtures/wpt/wasm/jsapi/instance/toString.any.js +++ b/test/fixtures/wpt/wasm/jsapi/instance/toString.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/instanceTestFactory.js b/test/fixtures/wpt/wasm/jsapi/instanceTestFactory.js index ac468947ec22e2..2e015af8198a61 100644 --- a/test/fixtures/wpt/wasm/jsapi/instanceTestFactory.js +++ b/test/fixtures/wpt/wasm/jsapi/instanceTestFactory.js @@ -237,7 +237,7 @@ const instanceTestFactory = [ builder.addGlobal(kWasmI32, true) .exportAs("") - .init = 7; + .init = wasmI32Const(7); const buffer = builder.toBuffer(); @@ -273,10 +273,10 @@ const instanceTestFactory = [ builder.addGlobal(kWasmI32, true) .exportAs("global") - .init = 7; + .init = wasmI32Const(7); builder.addGlobal(kWasmF64, true) .exportAs("global2") - .init = 1.2; + .init = wasmF64Const(1.2); builder.addMemory(4, 8, true); @@ -759,3 +759,5 @@ const instanceTestFactory = [ } ], ]; + +globalThis.instanceTestFactory = instanceTestFactory; diff --git a/test/fixtures/wpt/wasm/jsapi/interface.any.js b/test/fixtures/wpt/wasm/jsapi/interface.any.js index 19d29ead0a7264..8256fc209a7f65 100644 --- a/test/fixtures/wpt/wasm/jsapi/interface.any.js +++ b/test/fixtures/wpt/wasm/jsapi/interface.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function test_operations(object, object_name, operations) { diff --git a/test/fixtures/wpt/wasm/jsapi/js-string/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/js-string/WEB_FEATURES.yml new file mode 100644 index 00000000000000..6c1e9bb84c92f7 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/js-string/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm-string-builtins + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/js-string/basic.any.js b/test/fixtures/wpt/wasm/jsapi/js-string/basic.any.js new file mode 100644 index 00000000000000..174bab32eb171b --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/js-string/basic.any.js @@ -0,0 +1,383 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/assertions.js +// META: script=/wasm/jsapi/wasm-module-builder.js +// META: script=/wasm/jsapi/js-string/polyfill.js + +// Generate two sets of exports, one from a polyfill implementation and another +// from the builtins provided by the host. +let polyfillExports; +let builtinExports; +setup(() => { + // Compile a module that exports a function for each builtin that will call + // it. We could just generate a module that re-exports the builtins, but that + // would not catch any special codegen that could happen when direct calling + // a known builtin function from wasm. + const builder = new WasmModuleBuilder(); + const arrayIndex = builder.addArray(kWasmI16, true, kNoSuperType, true); + const builtins = [ + { + name: "test", + params: [kWasmExternRef], + results: [kWasmI32], + }, + { + name: "cast", + params: [kWasmExternRef], + results: [wasmRefType(kWasmExternRef)], + }, + { + name: "fromCharCodeArray", + params: [wasmRefNullType(arrayIndex), kWasmI32, kWasmI32], + results: [wasmRefType(kWasmExternRef)], + }, + { + name: "intoCharCodeArray", + params: [kWasmExternRef, wasmRefNullType(arrayIndex), kWasmI32], + results: [kWasmI32], + }, + { + name: "fromCharCode", + params: [kWasmI32], + results: [wasmRefType(kWasmExternRef)], + }, + { + name: "fromCodePoint", + params: [kWasmI32], + results: [wasmRefType(kWasmExternRef)], + }, + { + name: "charCodeAt", + params: [kWasmExternRef, kWasmI32], + results: [kWasmI32], + }, + { + name: "codePointAt", + params: [kWasmExternRef, kWasmI32], + results: [kWasmI32], + }, + { + name: "length", + params: [kWasmExternRef], + results: [kWasmI32], + }, + { + name: "concat", + params: [kWasmExternRef, kWasmExternRef], + results: [wasmRefType(kWasmExternRef)], + }, + { + name: "substring", + params: [kWasmExternRef, kWasmI32, kWasmI32], + results: [wasmRefType(kWasmExternRef)], + }, + { + name: "equals", + params: [kWasmExternRef, kWasmExternRef], + results: [kWasmI32], + }, + { + name: "compare", + params: [kWasmExternRef, kWasmExternRef], + results: [kWasmI32], + }, + ]; + + // Add a function type for each builtin + for (let builtin of builtins) { + builtin.type = builder.addType({ + params: builtin.params, + results: builtin.results + }); + } + + // Add an import for each builtin + for (let builtin of builtins) { + builtin.importFuncIndex = builder.addImport( + "wasm:js-string", + builtin.name, + builtin.type); + } + + // Generate an exported function to call the builtin + for (let builtin of builtins) { + let func = builder.addFunction(builtin.name + "Imp", builtin.type); + func.addLocals(builtin.params.length); + let body = []; + for (let i = 0; i < builtin.params.length; i++) { + body.push(kExprLocalGet); + body.push(...wasmSignedLeb(i)); + } + body.push(kExprCallFunction); + body.push(...wasmSignedLeb(builtin.importFuncIndex)); + func.addBody(body); + func.exportAs(builtin.name); + } + + const buffer = builder.toBuffer(); + + // Instantiate this module using the builtins from the host + const builtinModule = new WebAssembly.Module(buffer, { + builtins: ["js-string"] + }); + const builtinInstance = new WebAssembly.Instance(builtinModule, {}); + builtinExports = builtinInstance.exports; + + // Instantiate this module using the polyfill module + const polyfillModule = new WebAssembly.Module(buffer); + const polyfillInstance = new WebAssembly.Instance(polyfillModule, { + "wasm:js-string": polyfillImports + }); + polyfillExports = polyfillInstance.exports; +}); + +// A helper function to assert that the behavior of two functions are the +// same. +function assert_same_behavior(funcA, funcB, ...params) { + let resultA; + let errA = null; + try { + resultA = funcA(...params); + } catch (err) { + errA = err; + } + + let resultB; + let errB = null; + try { + resultB = funcB(...params); + } catch (err) { + errB = err; + } + + if (errA || errB) { + assert_equals(errA === null, errB === null, errA ? errA.message : errB.message); + assert_equals(Object.getPrototypeOf(errA), Object.getPrototypeOf(errB)); + } + assert_equals(resultA, resultB); + + if (errA) { + throw errA; + } + return resultA; +} + +function assert_throws_if(func, shouldThrow, constructor) { + let error = null; + try { + func(); + } catch (e) { + error = e; + } + assert_equals(error !== null, shouldThrow, "shouldThrow mismatch"); + if (shouldThrow && error !== null) { + assert_true(error instanceof constructor); + } +} + +// Constant values used in the tests below +const testStrings = [ + "", + "a", + "1", + "ab", + "hello, world", + "\n", + "☺", + "☺☺", + String.fromCodePoint(0x10000, 0x10001) +]; +const testCharCodes = [1, 2, 3, 10, 0x7f, 0xff, 0xfffe, 0xffff]; +const testCodePoints = [1, 2, 3, 10, 0x7f, 0xff, 0xfffe, 0xffff, 0x10000, 0x10001]; +const testExternRefValues = [ + null, + undefined, + true, + false, + {x:1337}, + ["abracadabra"], + 13.37, + -0, + 0x7fffffff + 0.1, + -0x7fffffff - 0.1, + 0x80000000 + 0.1, + -0x80000000 - 0.1, + 0xffffffff + 0.1, + -0xffffffff - 0.1, + Number.EPSILON, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + Number.MIN_VALUE, + Number.MAX_VALUE, + Number.NaN, + "hi", + 37n, + new Number(42), + new Boolean(true), + Symbol("status"), + () => 1337, +]; + +// Test that `test` and `cast` work on various JS values. Run all the +// other builtins and assert that they also perform equivalent type +// checks. +test(() => { + for (let a of testExternRefValues) { + let isString = assert_same_behavior( + builtinExports['test'], + polyfillExports['test'], + a + ); + + assert_throws_if(() => assert_same_behavior( + builtinExports['cast'], + polyfillExports['cast'], + a + ), !isString, WebAssembly.RuntimeError); + + let arrayMutI16 = helperExports.createArrayMutI16(10); + assert_throws_if(() => assert_same_behavior( + builtinExports['intoCharCodeArray'], + polyfillExports['intoCharCodeArray'], + a, arrayMutI16, 0 + ), !isString, WebAssembly.RuntimeError); + + assert_throws_if(() => assert_same_behavior( + builtinExports['charCodeAt'], + polyfillExports['charCodeAt'], + a, 0 + ), !isString, WebAssembly.RuntimeError); + + assert_throws_if(() => assert_same_behavior( + builtinExports['codePointAt'], + polyfillExports['codePointAt'], + a, 0 + ), !isString, WebAssembly.RuntimeError); + + assert_throws_if(() => assert_same_behavior( + builtinExports['length'], + polyfillExports['length'], + a + ), !isString, WebAssembly.RuntimeError); + + assert_throws_if(() => assert_same_behavior( + builtinExports['concat'], + polyfillExports['concat'], + a, a + ), !isString, WebAssembly.RuntimeError); + + assert_throws_if(() => assert_same_behavior( + builtinExports['substring'], + polyfillExports['substring'], + a, 0, 0 + ), !isString, WebAssembly.RuntimeError); + + assert_throws_if(() => assert_same_behavior( + builtinExports['equals'], + polyfillExports['equals'], + a, a + ), a !== null && !isString, WebAssembly.RuntimeError); + + assert_throws_if(() => assert_same_behavior( + builtinExports['compare'], + polyfillExports['compare'], + a, a + ), !isString, WebAssembly.RuntimeError); + } +}); + +// Test that `fromCharCode` works on various char codes +test(() => { + for (let a of testCharCodes) { + assert_same_behavior( + builtinExports['fromCharCode'], + polyfillExports['fromCharCode'], + a + ); + } +}); + +// Test that `fromCodePoint` works on various code points +test(() => { + for (let a of testCodePoints) { + assert_same_behavior( + builtinExports['fromCodePoint'], + polyfillExports['fromCodePoint'], + a + ); + } +}); + +// Perform tests on various strings +test(() => { + for (let a of testStrings) { + let length = assert_same_behavior( + builtinExports['length'], + polyfillExports['length'], + a + ); + + for (let i = 0; i < length; i++) { + let charCode = assert_same_behavior( + builtinExports['charCodeAt'], + polyfillExports['charCodeAt'], + a, i + ); + } + + for (let i = 0; i < length; i++) { + let charCode = assert_same_behavior( + builtinExports['codePointAt'], + polyfillExports['codePointAt'], + a, i + ); + } + + let arrayMutI16 = helperExports.createArrayMutI16(length); + assert_same_behavior( + builtinExports['intoCharCodeArray'], + polyfillExports['intoCharCodeArray'], + a, arrayMutI16, 0 + ); + + assert_same_behavior( + builtinExports['fromCharCodeArray'], + polyfillExports['fromCharCodeArray'], + arrayMutI16, 0, length + ); + + for (let i = 0; i < length; i++) { + for (let j = 0; j < length; j++) { + assert_same_behavior( + builtinExports['substring'], + polyfillExports['substring'], + a, i, j + ); + } + } + } +}); + +// Test various binary operations +test(() => { + for (let a of testStrings) { + for (let b of testStrings) { + assert_same_behavior( + builtinExports['concat'], + polyfillExports['concat'], + a, b + ); + + assert_same_behavior( + builtinExports['equals'], + polyfillExports['equals'], + a, b + ); + + assert_same_behavior( + builtinExports['compare'], + polyfillExports['compare'], + a, b + ); + } + } +}); diff --git a/test/fixtures/wpt/wasm/jsapi/js-string/constants.any.js b/test/fixtures/wpt/wasm/jsapi/js-string/constants.any.js new file mode 100644 index 00000000000000..90cf94df76efcb --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/js-string/constants.any.js @@ -0,0 +1,61 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +// Instantiate a module with an imported global and return the global. +function instantiateImportedGlobal(module, name, type, mutable, importedStringConstants) { + let builder = new WasmModuleBuilder(); + builder.addImportedGlobal(module, name, type, mutable); + builder.addExportOfKind("global", kExternalGlobal, 0); + let bytes = builder.toBuffer(); + let mod = new WebAssembly.Module(bytes, { importedStringConstants }); + let instance = new WebAssembly.Instance(mod, {}); + return instance.exports["global"]; +} + +const badGlobalTypes = [ + [kWasmAnyRef, false], + [kWasmAnyRef, true], + [wasmRefType(kWasmAnyRef), false], + [wasmRefType(kWasmAnyRef), true], + [kWasmFuncRef, false], + [kWasmFuncRef, true], + [wasmRefType(kWasmFuncRef), false], + [wasmRefType(kWasmFuncRef), true], + [kWasmExternRef, true], + [wasmRefType(kWasmExternRef), true], +]; +for ([type, mutable] of badGlobalTypes) { + test(() => { + assert_throws_js(WebAssembly.CompileError, + () => instantiateImportedGlobal("'", "constant", type, mutable, "'"), + "type mismatch"); + }); +} + +const goodGlobalTypes = [ + [kWasmExternRef, false], + [wasmRefType(kWasmExternRef), false], +]; +const constants = [ + '', + '\0', + '0', + '0'.repeat(100000), + '\uD83D\uDE00', +]; +const namespaces = [ + "", + "'", + "strings" +]; + +for (let namespace of namespaces) { + for (let constant of constants) { + for ([type, mutable] of goodGlobalTypes) { + test(() => { + let result = instantiateImportedGlobal(namespace, constant, type, mutable, namespace); + assert_equals(result.value, constant); + }); + } + } +} diff --git a/test/fixtures/wpt/wasm/jsapi/js-string/imports.any.js b/test/fixtures/wpt/wasm/jsapi/js-string/imports.any.js new file mode 100644 index 00000000000000..d4e16dc47d060b --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/js-string/imports.any.js @@ -0,0 +1,26 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +test(() => { + let builder = new WasmModuleBuilder(); + + // Import a string constant + builder.addImportedGlobal("constants", "constant", kWasmExternRef, false); + + // Import a builtin function + builder.addImport( + "wasm:js-string", + "test", + {params: [kWasmExternRef], results: [kWasmI32]}); + + let buffer = builder.toBuffer(); + let module = new WebAssembly.Module(buffer, { + builtins: ["js-string"], + importedStringConstants: "constants" + }); + let imports = WebAssembly.Module.imports(module); + + // All imports that refer to a builtin module are suppressed from import + // reflection. + assert_equals(imports.length, 0); +}); diff --git a/test/fixtures/wpt/wasm/jsapi/js-string/polyfill.js b/test/fixtures/wpt/wasm/jsapi/js-string/polyfill.js new file mode 100644 index 00000000000000..7a00d4285d7a26 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/js-string/polyfill.js @@ -0,0 +1,170 @@ +// Generate some helper functions for manipulating (array (mut i16)) from JS +let helperExports; +{ + const builder = new WasmModuleBuilder(); + const arrayIndex = builder.addArray(kWasmI16, true, kNoSuperType, true); + + builder + .addFunction("createArrayMutI16", { + params: [kWasmI32], + results: [kWasmAnyRef] + }) + .addBody([ + kExprLocalGet, + ...wasmSignedLeb(0), + ...GCInstr(kExprArrayNewDefault), + ...wasmSignedLeb(arrayIndex) + ]) + .exportFunc(); + + builder + .addFunction("arrayLength", { + params: [kWasmArrayRef], + results: [kWasmI32] + }) + .addBody([ + kExprLocalGet, + ...wasmSignedLeb(0), + ...GCInstr(kExprArrayLen) + ]) + .exportFunc(); + + builder + .addFunction("arraySet", { + params: [wasmRefNullType(arrayIndex), kWasmI32, kWasmI32], + results: [] + }) + .addBody([ + kExprLocalGet, + ...wasmSignedLeb(0), + kExprLocalGet, + ...wasmSignedLeb(1), + kExprLocalGet, + ...wasmSignedLeb(2), + ...GCInstr(kExprArraySet), + ...wasmSignedLeb(arrayIndex) + ]) + .exportFunc(); + + builder + .addFunction("arrayGet", { + params: [wasmRefNullType(arrayIndex), kWasmI32], + results: [kWasmI32] + }) + .addBody([ + kExprLocalGet, + ...wasmSignedLeb(0), + kExprLocalGet, + ...wasmSignedLeb(1), + ...GCInstr(kExprArrayGetU), + ...wasmSignedLeb(arrayIndex) + ]) + .exportFunc(); + + let bytes = builder.toBuffer(); + let module = new WebAssembly.Module(bytes); + let instance = new WebAssembly.Instance(module); + + helperExports = instance.exports; +} + +function throwIfNotString(a) { + if (typeof a !== "string") { + throw new WebAssembly.RuntimeError(); + } +} + +this.polyfillImports = { + test: (string) => { + if (string === null || + typeof string !== "string") { + return 0; + } + return 1; + }, + cast: (string) => { + throwIfNotString(string); + return string; + }, + fromCharCodeArray: (array, arrayStart, arrayCount) => { + arrayStart >>>= 0; + arrayCount >>>= 0; + let length = helperExports.arrayLength(array); + if (BigInt(arrayStart) + BigInt(arrayCount) > BigInt(length)) { + throw new WebAssembly.RuntimeError(); + } + let result = ''; + for (let i = arrayStart; i < arrayStart + arrayCount; i++) { + result += String.fromCharCode(helperExports.arrayGet(array, i)); + } + return result; + }, + intoCharCodeArray: (string, arr, arrayStart) => { + arrayStart >>>= 0; + throwIfNotString(string); + let arrLength = helperExports.arrayLength(arr); + let stringLength = string.length; + if (BigInt(arrayStart) + BigInt(stringLength) > BigInt(arrLength)) { + throw new WebAssembly.RuntimeError(); + } + for (let i = 0; i < stringLength; i++) { + helperExports.arraySet(arr, arrayStart + i, string[i].charCodeAt(0)); + } + return stringLength; + }, + fromCharCode: (charCode) => { + charCode >>>= 0; + return String.fromCharCode(charCode); + }, + fromCodePoint: (codePoint) => { + codePoint >>>= 0; + return String.fromCodePoint(codePoint); + }, + charCodeAt: (string, stringIndex) => { + stringIndex >>>= 0; + throwIfNotString(string); + if (stringIndex >= string.length) + throw new WebAssembly.RuntimeError(); + return string.charCodeAt(stringIndex); + }, + codePointAt: (string, stringIndex) => { + stringIndex >>>= 0; + throwIfNotString(string); + if (stringIndex >= string.length) + throw new WebAssembly.RuntimeError(); + return string.codePointAt(stringIndex); + }, + length: (string) => { + throwIfNotString(string); + return string.length; + }, + concat: (stringA, stringB) => { + throwIfNotString(stringA); + throwIfNotString(stringB); + return stringA + stringB; + }, + substring: (string, startIndex, endIndex) => { + startIndex >>>= 0; + endIndex >>>= 0; + throwIfNotString(string); + if (startIndex > string.length, + endIndex > string.length, + endIndex < startIndex) { + return ""; + } + return string.substring(startIndex, endIndex); + }, + equals: (stringA, stringB) => { + if (stringA !== null) throwIfNotString(stringA); + if (stringB !== null) throwIfNotString(stringB); + return stringA === stringB; + }, + compare: (stringA, stringB) => { + throwIfNotString(stringA); + throwIfNotString(stringB); + if (stringA < stringB) { + return -1; + } + return stringA === stringB ? 0 : 1; + }, +}; diff --git a/test/fixtures/wpt/wasm/jsapi/jspi/README.txt b/test/fixtures/wpt/wasm/jsapi/jspi/README.txt new file mode 100644 index 00000000000000..c65b9893c6fd30 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/jspi/README.txt @@ -0,0 +1,3 @@ +This suite tests JSPI. + +The tests are based on wasm spec tests. diff --git a/test/fixtures/wpt/wasm/jsapi/jspi/js-promise-integration.any.js b/test/fixtures/wpt/wasm/jsapi/jspi/js-promise-integration.any.js new file mode 100644 index 00000000000000..01dba0e3dadc70 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/jspi/js-promise-integration.any.js @@ -0,0 +1,370 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +// Test for invalid wrappers +test(() => { + assert_throws_js(TypeError, () => WebAssembly.promising({}), + "Argument 0 must be a function"); + assert_throws_js(TypeError, () => WebAssembly.promising(() => {}), + "Argument 0 must be a WebAssembly exported function"); + assert_throws_js(TypeError, () => WebAssembly.Suspending(() => {}), + "WebAssembly.Suspending must be invoked with 'new'"); + assert_throws_js(TypeError, () => new WebAssembly.Suspending({}), + "Argument 0 must be a function"); + + function asmModule() { + "use asm"; + + function x(v) { + v = v | 0; + } + return x; + } + assert_throws_js(TypeError, () => WebAssembly.promising(asmModule()), + "Argument 0 must be a WebAssembly exported function"); +},"Valid use of API"); + +test(() => { + let builder = new WasmModuleBuilder(); + builder.addGlobal(kWasmI32, true).exportAs('g'); + builder.addFunction("test", kSig_i_v) + .addBody([ + kExprI32Const, 42, + kExprGlobalSet, 0, + kExprI32Const, 0 + ]).exportFunc(); + let instance = builder.instantiate(); + let wrapper = WebAssembly.promising(instance.exports.test); + wrapper(); + assert_equals(42, instance.exports.g.value); +},"Promising function always entered"); + +promise_test(async () => { + let builder = new WasmModuleBuilder(); + let import_index = builder.addImport('m', 'import', kSig_i_v); + builder.addFunction("test", kSig_i_i) + .addBody([ + kExprCallFunction, import_index, // suspend + ]).exportFunc(); + let js_import = () => 42; + let instance = builder.instantiate({ + m: { + import: js_import + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + let export_promise = wrapped_export(); + assert_true(export_promise instanceof Promise); + assert_equals(await export_promise, 42); +}, "Always get a Promise"); + +promise_test(async () => { + let builder = new WasmModuleBuilder(); + let import_index = builder.addImport('m', 'import', kSig_i_i); + builder.addFunction("test", kSig_i_i) + .addBody([ + kExprLocalGet, 0, + kExprCallFunction, import_index, // suspend + ]).exportFunc(); + let js_import = new WebAssembly.Suspending(() => Promise.resolve(42)); + let instance = builder.instantiate({ + m: { + import: js_import + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + let export_promise = wrapped_export(); + assert_true(export_promise instanceof Promise); + assert_equals(await export_promise, 42); +}, "Suspend once"); + +promise_test(async () => { + let builder = new WasmModuleBuilder(); + builder.addGlobal(kWasmI32, true).exportAs('g'); + let import_index = builder.addImport('m', 'import', kSig_i_v); + // void test() { + // for (i = 0; i < 5; ++i) { + // g = g + await import(); + // } + // } + builder.addFunction("test", kSig_v_i) + .addLocals({ + i32_count: 1 + }) + .addBody([ + kExprI32Const, 5, + kExprLocalSet, 1, + kExprLoop, kWasmStmt, + kExprCallFunction, import_index, // suspend + kExprGlobalGet, 0, + kExprI32Add, + kExprGlobalSet, 0, + kExprLocalGet, 1, + kExprI32Const, 1, + kExprI32Sub, + kExprLocalTee, 1, + kExprBrIf, 0, + kExprEnd, + ]).exportFunc(); + let i = 0; + + function js_import() { + return Promise.resolve(++i); + }; + let wasm_js_import = new WebAssembly.Suspending(js_import); + let instance = builder.instantiate({ + m: { + import: wasm_js_import + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + let export_promise = wrapped_export(); + assert_equals(instance.exports.g.value, 0); + assert_true(export_promise instanceof Promise); + await export_promise; + assert_equals(instance.exports.g.value, 15); +}, "Suspend/resume in a loop"); + +promise_test(async () => { + let builder = new WasmModuleBuilder(); + let import_index = builder.addImport('m', 'import', kSig_i_v); + builder.addFunction("test", kSig_i_v) + .addBody([ + kExprCallFunction, import_index, // suspend + ]).exportFunc(); + let js_import = new WebAssembly.Suspending(() => Promise.resolve(42)); + let instance = builder.instantiate({ + m: { + import: js_import + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + assert_equals(await wrapped_export(), 42); + + // Also try with a JS function with a mismatching arity. + js_import = new WebAssembly.Suspending((unused) => Promise.resolve(42)); + instance = builder.instantiate({ + m: { + import: js_import + } + }); + wrapped_export = WebAssembly.promising(instance.exports.test); + assert_equals(await wrapped_export(), 42); + + // Also try with a proxy. + js_import = new WebAssembly.Suspending(new Proxy(() => Promise.resolve(42), {})); + instance = builder.instantiate({ + m: { + import: js_import + } + }); + wrapped_export = WebAssembly.promising(instance.exports.test); + assert_equals(await wrapped_export(), 42); +},"Suspending with mismatched args and via Proxy"); + +function recordAbeforeB() { + let AbeforeB = []; + let setA = () => { + AbeforeB.push("A") + } + let setB = () => { + AbeforeB.push("B") + } + let isAbeforeB = () => + AbeforeB[0] == "A" && AbeforeB[1] == "B"; + + let isBbeforeA = () => + AbeforeB[0] == "B" && AbeforeB[1] == "A"; + + return { + setA: setA, + setB: setB, + isAbeforeB: isAbeforeB, + isBbeforeA: isBbeforeA, + } +} + +promise_test(async () => { + let builder = new WasmModuleBuilder(); + let AbeforeB = recordAbeforeB(); + let import42_index = builder.addImport('m', 'import42', kSig_i_i); + let importSetA_index = builder.addImport('m', 'setA', kSig_v_v); + builder.addFunction("test", kSig_i_i) + .addBody([ + kExprLocalGet, 0, + kExprCallFunction, import42_index, // suspend? + kExprCallFunction, importSetA_index + ]).exportFunc(); + let import42 = new WebAssembly.Suspending(() => Promise.resolve(42)); + let instance = builder.instantiate({ + m: { + import42: import42, + setA: AbeforeB.setA + } + }); + + let wrapped_export = WebAssembly.promising(instance.exports.test); + + let exported_promise = wrapped_export(); + + AbeforeB.setB(); + + assert_equals(await exported_promise, 42); + + assert_true(AbeforeB.isBbeforeA()); +}, "Make sure we actually suspend"); + +promise_test(async () => { + let builder = new WasmModuleBuilder(); + let AbeforeB = recordAbeforeB(); + let import42_index = builder.addImport('m', 'import42', kSig_i_i); + let importSetA_index = builder.addImport('m', 'setA', kSig_v_v); + builder.addFunction("test", kSig_i_i) + .addBody([ + kExprLocalGet, 0, + kExprCallFunction, import42_index, // suspend? + kExprCallFunction, importSetA_index + ]).exportFunc(); + let import42 = new WebAssembly.Suspending(() => 42); + let instance = builder.instantiate({ + m: { + import42: import42, + setA: AbeforeB.setA + } + }); + + let wrapped_export = WebAssembly.promising(instance.exports.test); + + let exported_promise = wrapped_export(); + AbeforeB.setB(); + + assert_equals(await exported_promise, 42); + assert_true(AbeforeB.isBbeforeA()); +}, "Do suspend even if the import's return value is not a Promise by wrapping it with Promise.resolve"); + +promise_test(async (t) => { + let tag = new WebAssembly.Tag({ + parameters: ['i32'] + }); + let builder = new WasmModuleBuilder(); + let import_index = builder.addImport('m', 'import', kSig_i_i); + let tag_index = builder.addImportedTag('m', 'tag', kSig_v_i); + builder.addFunction("test", kSig_i_i) + .addBody([ + kExprTry, kWasmI32, + kExprLocalGet, 0, + kExprCallFunction, import_index, + kExprCatch, tag_index, + kExprEnd + ]).exportFunc(); + + function js_import(unused) { + return Promise.reject(new WebAssembly.Exception(tag, [42])); + }; + let wasm_js_import = new WebAssembly.Suspending(js_import); + + let instance = builder.instantiate({ + m: { + import: wasm_js_import, + tag: tag + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + let export_promise = wrapped_export(); + assert_true(export_promise instanceof Promise); + assert_equals(await export_promise, 42); +}, "Catch rejected promise"); + +async function TestSandwich(suspend) { + // Set up a 'sandwich' scenario. The call chain looks like: + // top (wasm) -> outer (js) -> bottom (wasm) -> inner (js) + // If 'suspend' is true, the inner JS function returns a Promise, which + // suspends the bottom wasm function, which returns a Promise, which suspends + // the top wasm function, which returns a Promise. The inner Promise + // resolves first, which resumes the bottom continuation. Then the outer + // promise resolves which resumes the top continuation. + // If 'suspend' is false, the bottom JS function returns a regular value and + // no computation is suspended. + let builder = new WasmModuleBuilder(); + let inner_index = builder.addImport('m', 'inner', kSig_i_i); + let outer_index = builder.addImport('m', 'outer', kSig_i_i); + builder.addFunction("top", kSig_i_i) + .addBody([ + kExprLocalGet, 0, + kExprCallFunction, outer_index + ]).exportFunc(); + builder.addFunction("bottom", kSig_i_i) + .addBody([ + kExprLocalGet, 0, + kExprCallFunction, inner_index + ]).exportFunc(); + + let inner = new WebAssembly.Suspending(() => suspend ? Promise.resolve(42) : 43); + + let export_inner; + let outer = new WebAssembly.Suspending(() => export_inner()); + + let instance = builder.instantiate({ + m: { + inner, + outer + } + }); + export_inner = WebAssembly.promising(instance.exports.bottom); + let export_top = WebAssembly.promising(instance.exports.top); + let result = export_top(); + assert_true(result instanceof Promise); + if (suspend) + assert_equals(await result, 42); + else + assert_equals(await result, 43); +} + +promise_test(async () => { + TestSandwich(true); +}, "Test sandwich with suspension"); + +promise_test(async () => { + TestSandwich(false); +}, "Test sandwich with no suspension"); + +test(() => { + // Check that a promising function with no return is allowed. + let builder = new WasmModuleBuilder(); + builder.addFunction("export", kSig_v_v).addBody([]).exportFunc(); + let instance = builder.instantiate(); + let export_wrapper = WebAssembly.promising(instance.exports.export); + assert_true(export_wrapper instanceof Function); +},"Promising with no return"); + +promise_test(async () => { + let builder1 = new WasmModuleBuilder(); + let import_index = builder1.addImport('m', 'import', kSig_i_v); + builder1.addFunction("f", kSig_i_v) + .addBody([ + kExprCallFunction, import_index, // suspend + kExprI32Const, 1, + kExprI32Add, + ]).exportFunc(); + let js_import = new WebAssembly.Suspending(() => Promise.resolve(1)); + let instance1 = builder1.instantiate({ + m: { + import: js_import + } + }); + let builder2 = new WasmModuleBuilder(); + import_index = builder2.addImport('m', 'import', kSig_i_v); + builder2.addFunction("main", kSig_i_v) + .addBody([ + kExprCallFunction, import_index, + kExprI32Const, 1, + kExprI32Add, + ]).exportFunc(); + let instance2 = builder2.instantiate({ + m: { + import: instance1.exports.f + } + }); + let wrapped_export = WebAssembly.promising(instance2.exports.main); + assert_equals(await wrapped_export(), 3); +},"Suspend two modules"); \ No newline at end of file diff --git a/test/fixtures/wpt/wasm/jsapi/jspi/notraps.any.js b/test/fixtures/wpt/wasm/jsapi/jspi/notraps.any.js new file mode 100644 index 00000000000000..4060885f36cb6a --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/jspi/notraps.any.js @@ -0,0 +1,82 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js +// META: script=/wasm/jsapi/jspi/testharness-additions.js + +test(() => { + let builder = new WasmModuleBuilder(); + let js_tag = builder.addImportedTag("", "tag", kSig_v_r); + let try_sig_index = builder.addType(kSig_i_v); + + let promise42 = new WebAssembly.Suspending(() => Promise.resolve(42)); + let kPromise42Ref = builder.addImport("", "promise42", kSig_i_v); + + builder.addFunction("test", kSig_i_v) + .addBody([ + kExprTry, try_sig_index, + kExprCallFunction, kPromise42Ref, + kExprReturn, // If there was no trap or exception, return + kExprCatch, js_tag, + kExprI32Const, 43, + kExprReturn, + kExprEnd, + ]) + .exportFunc(); + + let instance = builder.instantiate({"": { + promise42: promise42, + tag: WebAssembly.JSTag, + }}); + + assert_equals(43, instance.exports.test()); + },"catch SuspendError"); + +promise_test(async t=>{ + let builder = new WasmModuleBuilder(); + let js_tag = builder.addImportedTag("", "tag", kSig_v_r); + let try_sig_index = builder.addType(kSig_i_v); + + let promise42 = new WebAssembly.Suspending(() => Promise.resolve(42)); + let kPromise42Ref = builder.addImport("", "promise42", kSig_i_v); + let backChnnlRef = builder.addImport("","backChnnl",kSig_i_v); + + builder.addFunction("main", kSig_i_v) + .addBody([ + kExprTry, try_sig_index, + kExprCallFunction, backChnnlRef, + kExprReturn, // If there was no trap or exception, return + kExprCatch, js_tag, + kExprI32Const, 43, + kExprReturn, + kExprEnd, + ]) + .exportFunc(); + + builder.addFunction("inner", kSig_i_v) + .addBody([ + kExprCallFunction, kPromise42Ref, + ]) + .exportFunc(); + + let backChnnl = ()=>instance.exports.inner(); + let instance = builder.instantiate({"": { + promise42: promise42, + backChnnl: backChnnl, + tag: WebAssembly.JSTag, + }}); + + wrapped_export = WebAssembly.promising(instance.exports.main); + + assert_equals(await wrapped_export(), 43); + },"throw on reentrance"); + +test(() => { + let builder = new WasmModuleBuilder(); + import_index = builder.addImport('m', 'import', kSig_v_v); + builder.addFunction("test", kSig_v_v) + .addBody([ + kExprCallFunction, import_index, + ]).exportFunc(); + let js_import = new WebAssembly.Suspending(() => Promise.resolve()); + let instance = builder.instantiate({m: {import: js_import}}); + assert_throws_js(WebAssembly.SuspendError, instance.exports.test); +},"unwrapped export"); diff --git a/test/fixtures/wpt/wasm/jsapi/jspi/rejects.any.js b/test/fixtures/wpt/wasm/jsapi/jspi/rejects.any.js new file mode 100644 index 00000000000000..55c5abc9a8a6ca --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/jspi/rejects.any.js @@ -0,0 +1,150 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js +// META: script=/wasm/jsapi/jspi/testharness-additions.js + +promise_test(t => { + let tag = new WebAssembly.Tag({ + parameters: [] + }); + let builder = new WasmModuleBuilder(); + import_index = builder.addImport('m', 'import', kSig_i_i); + tag_index = builder.addImportedTag('m', 'tag', kSig_v_v); + builder.addFunction("test", kSig_i_i) + .addBody([ + kExprLocalGet, 0, + kExprCallFunction, import_index, + kExprThrow, tag_index + ]).exportFunc(); + + function js_import() { + return Promise.resolve(); + }; + let wasm_js_import = new WebAssembly.Suspending(js_import); + + let instance = builder.instantiate({ + m: { + import: wasm_js_import, + tag: tag + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + let export_promise = wrapped_export(); + assert_true(export_promise instanceof Promise); + return promise_rejects_jspi(t, new WebAssembly.Exception(tag, []), export_promise); +}, "Throw after the first suspension"); + +// Throw an exception before suspending. The export wrapper should return a +// promise rejected with the exception. +promise_test(async (t) => { + let tag = new WebAssembly.Tag({ + parameters: [] + }); + let builder = new WasmModuleBuilder(); + tag_index = builder.addImportedTag('m', 'tag', kSig_v_v); + builder.addFunction("test", kSig_i_v) + .addBody([ + kExprThrow, tag_index + ]).exportFunc(); + + let instance = builder.instantiate({ + m: { + tag: tag + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + let export_promise = wrapped_export(); + + promise_rejects_jspi(t, new WebAssembly.Exception(tag, []), export_promise); +}, "Throw before suspending"); + +// Throw an exception after the first resume event, which propagates to the +// promise wrapper. +promise_test(async (t) => { + let tag = new WebAssembly.Tag({ + parameters: [] + }); + let builder = new WasmModuleBuilder(); + import_index = builder.addImport('m', 'import', kSig_i_v); + tag_index = builder.addImportedTag('m', 'tag', kSig_v_v); + builder.addFunction("test", kSig_i_v) + .addBody([ + kExprCallFunction, import_index, + kExprThrow, tag_index + ]).exportFunc(); + + function js_import() { + return Promise.resolve(42); + }; + let wasm_js_import = new WebAssembly.Suspending(js_import); + + let instance = builder.instantiate({ + m: { + import: wasm_js_import, + tag: tag + } + }); + let wrapped_export = WebAssembly.promising(instance.exports.test); + let export_promise = wrapped_export(); + + promise_rejects_jspi(t, new WebAssembly.Exception(tag, []), export_promise); +}, "Throw and propagate via Promise"); + +promise_test(async (t) => { + let builder = new WasmModuleBuilder(); + builder.addFunction("test", kSig_i_v) + .addBody([ + kExprCallFunction, 0 + ]).exportFunc(); + let instance = builder.instantiate(); + let wrapper = WebAssembly.promising(instance.exports.test); + + // Stack overflow has undefined behavior -- check if an exception was thrown. + let thrown = false; + try { + await wrapper(); + } catch (e) { + thrown = true; + } + assert_true(thrown); +}, "Stack overflow"); + +promise_test(async (t) => { + // The call stack of this test looks like: + // export1 -> import1 -> export2 -> import2 + // Where export1 is "promising" and import2 is "suspending". Returning a + // promise from import2 should trap because of the JS import in the middle. + let builder = new WasmModuleBuilder(); + let import1_index = builder.addImport("m", "import1", kSig_i_v); + let import2_index = builder.addImport("m", "import2", kSig_i_v); + builder.addFunction("export1", kSig_i_v) + .addBody([ + // export1 -> import1 (unwrapped) + kExprCallFunction, import1_index, + ]).exportFunc(); + builder.addFunction("export2", kSig_i_v) + .addBody([ + // export2 -> import2 (suspending) + kExprCallFunction, import2_index, + ]).exportFunc(); + let instance; + + function import1() { + // import1 -> export2 (unwrapped) + instance.exports.export2(); + } + + function import2() { + return Promise.resolve(0); + } + import2 = new WebAssembly.Suspending(import2); + instance = builder.instantiate({ + 'm': { + 'import1': import1, + 'import2': import2 + } + }); + // export1 (promising) + let wrapper = WebAssembly.promising(instance.exports.export1); + promise_rejects_js(t, WebAssembly.SuspendError, wrapper(), + "trying to suspend JS frames"); +}, "Try to suspend JS"); \ No newline at end of file diff --git a/test/fixtures/wpt/wasm/jsapi/jspi/testharness-additions.js b/test/fixtures/wpt/wasm/jsapi/jspi/testharness-additions.js new file mode 100644 index 00000000000000..e146c52f96dfba --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/jspi/testharness-additions.js @@ -0,0 +1,26 @@ +// Copyright 2020 the V8 project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +function assert_throws_jspi(code, func, description) { + try { + func(); + } catch (e) { + assert_true( + e.name === code.name, + 'expected exception ' + code.name + ', got ' + e.name); + return; + } + assert_true( + false, 'expected exception ' + code.name + ', no exception thrown'); + } + + function promise_rejects_jspi(test, expected, promise, description) { + return promise + .then(() => assert_unreached('Should have rejected: ' + description)) + .catch(function(e) { + assert_throws_jspi(expected, function() { + throw e; + }, description); + }); + } diff --git a/test/fixtures/wpt/wasm/jsapi/memory/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/memory/WEB_FEATURES.yml new file mode 100644 index 00000000000000..50f9ae19b6707b --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/memory/WEB_FEATURES.yml @@ -0,0 +1,12 @@ +features: +- name: wasm + files: + - "*" + - "!constructor-shared.tentative.any.js" + - "!to-fixed-length-buffer-shared.any.js" + - "!to-resizable-buffer-shared.any.js" +- name: wasm-threads + files: + - "constructor-shared.tentative.any.js" + - "to-fixed-length-buffer-shared.any.js" + - "to-resizable-buffer-shared.any.js" diff --git a/test/fixtures/wpt/wasm/jsapi/memory/assertions.js b/test/fixtures/wpt/wasm/jsapi/memory/assertions.js index b539513adcab7d..1430c523882307 100644 --- a/test/fixtures/wpt/wasm/jsapi/memory/assertions.js +++ b/test/fixtures/wpt/wasm/jsapi/memory/assertions.js @@ -26,6 +26,7 @@ function assert_ArrayBuffer(actual, { size=0, shared=false, detached=false }, me assert_equals(Object.isFrozen(actual), shared, "buffer frozen"); assert_equals(Object.isExtensible(actual), !shared, "buffer extensibility"); } +globalThis.assert_ArrayBuffer = assert_ArrayBuffer; function assert_Memory(memory, { size=0, shared=false }) { assert_equals(Object.getPrototypeOf(memory), WebAssembly.Memory.prototype, @@ -36,3 +37,4 @@ function assert_Memory(memory, { size=0, shared=false }) { assert_equals(memory.buffer, memory.buffer, "buffer should be idempotent"); assert_ArrayBuffer(memory.buffer, { size, shared }); } +globalThis.assert_Memory = assert_Memory; diff --git a/test/fixtures/wpt/wasm/jsapi/memory/constructor-shared.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/memory/constructor-shared.tentative.any.js index 216fc4ca55591f..0134b307749ed7 100644 --- a/test/fixtures/wpt/wasm/jsapi/memory/constructor-shared.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/memory/constructor-shared.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/memory/assertions.js diff --git a/test/fixtures/wpt/wasm/jsapi/memory/constructor-types.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/memory/constructor-types.tentative.any.js index d5378dbe82b00b..4653c6686a78e4 100644 --- a/test/fixtures/wpt/wasm/jsapi/memory/constructor-types.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/memory/constructor-types.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/memory/assertions.js @@ -17,4 +17,4 @@ test(() => { const argument = { minimum: 4 }; const memory = new WebAssembly.Memory(argument); assert_Memory(memory, { "size": 4 }); - }, "Non-zero minimum"); \ No newline at end of file + }, "Non-zero minimum"); diff --git a/test/fixtures/wpt/wasm/jsapi/memory/constructor.any.js b/test/fixtures/wpt/wasm/jsapi/memory/constructor.any.js index 0a0be11e370877..8f413c9f48a86d 100644 --- a/test/fixtures/wpt/wasm/jsapi/memory/constructor.any.js +++ b/test/fixtures/wpt/wasm/jsapi/memory/constructor.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/memory/assertions.js diff --git a/test/fixtures/wpt/wasm/jsapi/memory/grow.any.js b/test/fixtures/wpt/wasm/jsapi/memory/grow.any.js index c511129491f4de..2eafe5761eaa6d 100644 --- a/test/fixtures/wpt/wasm/jsapi/memory/grow.any.js +++ b/test/fixtures/wpt/wasm/jsapi/memory/grow.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/memory/assertions.js test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer-shared.any.js b/test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer-shared.any.js new file mode 100644 index 00000000000000..ddaab236f41d8d --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer-shared.any.js @@ -0,0 +1,17 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +test(() => { + const memory = new WebAssembly.Memory({ initial: 0, maximum: 4, shared: true }); + const buffer1 = memory.buffer; + + assert_false(buffer1.growable, "By default the SAB is initially not growable"); + + const buffer2 = memory.toFixedLengthBuffer(); + assert_equals(buffer1, buffer2, "Not changing resizability does not make a new object"); + + const buffer3 = memory.toResizableBuffer(); + assert_not_equals(buffer2, buffer3, "Changing resizability makes a new object"); + assert_true(buffer3.growable); + assert_equals(memory.buffer, buffer3); +}, "toFixedLengthBuffer caching behavior"); diff --git a/test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer.any.js b/test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer.any.js new file mode 100644 index 00000000000000..d5e6d65b74782f --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/memory/to-fixed-length-buffer.any.js @@ -0,0 +1,42 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +test(() => { + const thisValues = [ + undefined, + null, + true, + "", + Symbol(), + 1, + {}, + WebAssembly.Memory, + WebAssembly.Memory.prototype, + ]; + + const desc = Object.getOwnPropertyDescriptor(WebAssembly.Memory.prototype, "toFixedLengthBuffer"); + assert_equals(typeof desc, "object"); + + const fun = desc.value; + assert_equals(typeof desc.value, "function"); + + for (const thisValue of thisValues) { + assert_throws_js(TypeError, () => fun.call(thisValue), `this=${format_value(thisValue)}`); + } +}, "API surface"); + +test(() => { + const memory = new WebAssembly.Memory({ initial: 0, maximum: 1 }); + const buffer1 = memory.buffer; + + assert_false(buffer1.resizable, "By default the AB is initially not resizable"); + + const buffer2 = memory.toFixedLengthBuffer(); + assert_equals(buffer1, buffer2, "Not changing resizability does not make a new object"); + + const buffer3 = memory.toResizableBuffer(); + assert_not_equals(buffer2, buffer3, "Changing resizability makes a new object"); + assert_true(buffer3.resizable); + assert_true(buffer2.detached); + assert_equals(memory.buffer, buffer3); +}, "toFixedLengthBuffer caching behavior"); diff --git a/test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer-shared.any.js b/test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer-shared.any.js new file mode 100644 index 00000000000000..3ef0e80995751a --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer-shared.any.js @@ -0,0 +1,36 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +test(() => { + const memory = new WebAssembly.Memory({ initial: 0, maximum: 4, shared: true }); + const buffer1 = memory.buffer; + + assert_false(buffer1.growable, "By default the SAB is initially not growable"); + + const buffer2 = memory.toResizableBuffer(); + assert_true(buffer2.growable); + assert_not_equals(buffer1, buffer2, "Changing resizability makes a new object"); + assert_equals(memory.buffer, buffer2, "The buffer created by the most recent toFooBuffer call is cached"); + + const buffer3 = memory.toResizableBuffer(); + assert_equals(buffer2, buffer3, "toResizableBuffer does nothing if buffer is already resizable") + assert_equals(memory.buffer, buffer3); +}, "toResizableBuffer caching behavior"); + +test(() => { + const maxNumPages = 4; + const memory = new WebAssembly.Memory({ initial: 0, maximum: maxNumPages, shared: true }); + const buffer = memory.toResizableBuffer(); + assert_equals(buffer.maxByteLength, kPageSize * maxNumPages, "Memory maximum is same as maxByteLength"); +}, "toResizableBuffer max size"); + +test(() => { + const memory = new WebAssembly.Memory({ initial: 0, maximum: 4, shared: true }); + const buffer = memory.toResizableBuffer(); + + assert_equals(buffer.byteLength, 0); + buffer.grow(2 * kPageSize); + assert_equals(buffer.byteLength, 2 * kPageSize); + + assert_throws_js(RangeError, () => buffer.grow(3 * kPageSize - 1), "Can only grow by page multiples"); +}, "Resizing a Memory's resizable buffer"); diff --git a/test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer.any.js b/test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer.any.js new file mode 100644 index 00000000000000..1f46fa7f9a7e3b --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/memory/to-resizable-buffer.any.js @@ -0,0 +1,72 @@ +// META: global=window,dedicatedworker,jsshell +// META: script=/wasm/jsapi/wasm-module-builder.js + +test(() => { + const thisValues = [ + undefined, + null, + true, + "", + Symbol(), + 1, + {}, + WebAssembly.Memory, + WebAssembly.Memory.prototype, + ]; + + const desc = Object.getOwnPropertyDescriptor(WebAssembly.Memory.prototype, "toResizableBuffer"); + assert_equals(typeof desc, "object"); + + const fun = desc.value; + assert_equals(typeof desc.value, "function"); + + for (const thisValue of thisValues) { + assert_throws_js(TypeError, () => fun.call(thisValue), `this=${format_value(thisValue)}`); + } +}, "API surface"); + +test(() => { + const memory = new WebAssembly.Memory({ initial: 0, maximum: 1 }); + const buffer1 = memory.buffer; + + assert_false(buffer1.resizable, "By default the AB is initially not resizable"); + + const buffer2 = memory.toResizableBuffer(); + assert_true(buffer2.resizable); + assert_not_equals(buffer1, buffer2, "Changing resizability makes a new object"); + assert_true(buffer1.detached); + assert_equals(memory.buffer, buffer2, "The buffer created by the most recent toFooBuffer call is cached"); + + const buffer3 = memory.toResizableBuffer(); + assert_equals(buffer2, buffer3, "toResizableBuffer does nothing if buffer is already resizable") + assert_equals(memory.buffer, buffer3); + +}, "toResizableBuffer caching behavior"); + +test(() => { + { + const maxNumPages = 4; + const memory = new WebAssembly.Memory({ initial: 0, maximum: maxNumPages }); + const buffer = memory.toResizableBuffer(); + assert_equals(buffer.maxByteLength, kPageSize * maxNumPages, "Memory maximum is same as maxByteLength"); + } +}, "toResizableBuffer max size"); + +test(() => { + const maxNumPages = 4; + const memory = new WebAssembly.Memory({ initial: 0, maximum: maxNumPages }); + const buffer = memory.toResizableBuffer(); + + assert_equals(buffer.byteLength, 0); + buffer.resize(2 * kPageSize); + assert_equals(buffer.byteLength, 2 * kPageSize); + + assert_throws_js(RangeError, () => buffer.resize(3 * kPageSize - 1), "Can only grow by page multiples"); + assert_throws_js(RangeError, () => buffer.resize(1 * kPageSize), "Cannot shrink"); +}, "Resizing a Memory's resizable buffer"); + +test(() => { + const memory = new WebAssembly.Memory({ initial: 0, maximum: 1 }); + const buffer = memory.toResizableBuffer(); + assert_throws_js(TypeError, () => buffer.transfer(), "Cannot be detached by JS"); +}, "Resizable buffers from Memory cannot be detached by JS"); diff --git a/test/fixtures/wpt/wasm/jsapi/memory/type.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/memory/type.tentative.any.js index a96a3227adca7f..3f6531f5967ed7 100644 --- a/test/fixtures/wpt/wasm/jsapi/memory/type.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/memory/type.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function assert_type(argument) { @@ -34,4 +34,4 @@ test(() => { test(() => { assert_type({ "minimum": 0, "maximum": 10, "shared": true}); -}, "shared memory"); \ No newline at end of file +}, "shared memory"); diff --git a/test/fixtures/wpt/wasm/jsapi/module/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/module/WEB_FEATURES.yml new file mode 100644 index 00000000000000..d8e24fc0e8f503 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/module/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/module/constructor.any.js b/test/fixtures/wpt/wasm/jsapi/module/constructor.any.js index 9978f7e6ac8f2b..95604aabe475a8 100644 --- a/test/fixtures/wpt/wasm/jsapi/module/constructor.any.js +++ b/test/fixtures/wpt/wasm/jsapi/module/constructor.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=/wasm/jsapi/assertions.js diff --git a/test/fixtures/wpt/wasm/jsapi/module/customSections.any.js b/test/fixtures/wpt/wasm/jsapi/module/customSections.any.js index 4029877e92c7b8..96958316e061d7 100644 --- a/test/fixtures/wpt/wasm/jsapi/module/customSections.any.js +++ b/test/fixtures/wpt/wasm/jsapi/module/customSections.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js function assert_ArrayBuffer(buffer, expected) { diff --git a/test/fixtures/wpt/wasm/jsapi/module/exports.any.js b/test/fixtures/wpt/wasm/jsapi/module/exports.any.js index 40a3935a4a23ba..0c32e984a2cad1 100644 --- a/test/fixtures/wpt/wasm/jsapi/module/exports.any.js +++ b/test/fixtures/wpt/wasm/jsapi/module/exports.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js let emptyModuleBinary; @@ -109,10 +109,10 @@ test(() => { builder.addGlobal(kWasmI32, true) .exportAs("global") - .init = 7; + .init = wasmI32Const(7); builder.addGlobal(kWasmF64, true) .exportAs("global2") - .init = 1.2; + .init = wasmF64Const(1.2); builder.addMemory(0, 256, true); @@ -167,7 +167,7 @@ test(() => { builder.addGlobal(kWasmI32, true) .exportAs("") - .init = 7; + .init = wasmI32Const(7); const buffer = builder.toBuffer() const module = new WebAssembly.Module(buffer); diff --git a/test/fixtures/wpt/wasm/jsapi/module/imports.any.js b/test/fixtures/wpt/wasm/jsapi/module/imports.any.js index ec550ce6c41af1..2ab1725359f187 100644 --- a/test/fixtures/wpt/wasm/jsapi/module/imports.any.js +++ b/test/fixtures/wpt/wasm/jsapi/module/imports.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js function assert_ModuleImportDescriptor(import_, expected) { diff --git a/test/fixtures/wpt/wasm/jsapi/module/moduleSource.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/module/moduleSource.tentative.any.js new file mode 100644 index 00000000000000..8a94cdd48cd497 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/module/moduleSource.tentative.any.js @@ -0,0 +1,35 @@ +// META: global=window,dedicatedworker,jsshell,shadowrealm +// META: script=/wasm/jsapi/wasm-module-builder.js +// META: script=/wasm/jsapi/assertions.js + +let emptyModuleBinary; +setup(() => { + emptyModuleBinary = new WasmModuleBuilder().toBuffer(); +}); + +test(() => { + assert_equals(typeof AbstractModuleSource, "undefined"); +}, "AbstractModuleSource is not a global"); + +test(() => { + const AbstractModuleSource = Object.getPrototypeOf(WebAssembly.Module); + assert_equals(AbstractModuleSource.name, "AbstractModuleSource"); + assert_not_equals(AbstractModuleSource, Function); +}, "AbstractModuleSource intrinsic"); + +test(() => { + const AbstractModuleSourceProto = Object.getPrototypeOf(WebAssembly.Module.prototype); + assert_not_equals(AbstractModuleSourceProto, Object.prototype); + const AbstractModuleSource = Object.getPrototypeOf(WebAssembly.Module); + assert_equals(AbstractModuleSource.prototype, AbstractModuleSourceProto); +}, "AbstractModuleSourceProto intrinsic"); + +test(() => { + const module = new WebAssembly.Module(emptyModuleBinary); + + const AbstractModuleSource = Object.getPrototypeOf(WebAssembly.Module); + const toStringTag = Object.getOwnPropertyDescriptor(AbstractModuleSource.prototype, Symbol.toStringTag).get; + + assert_equals(toStringTag.call(module), "WebAssembly.Module"); + assert_throws_js(TypeError, () => toStringTag.call({})); +}, "AbstractModuleSourceProto toStringTag brand check"); \ No newline at end of file diff --git a/test/fixtures/wpt/wasm/jsapi/module/toString.any.js b/test/fixtures/wpt/wasm/jsapi/module/toString.any.js index 1c20cd6108c84e..10c707979d5bdc 100644 --- a/test/fixtures/wpt/wasm/jsapi/module/toString.any.js +++ b/test/fixtures/wpt/wasm/jsapi/module/toString.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/prototypes.any.js b/test/fixtures/wpt/wasm/jsapi/prototypes.any.js index 714f4f8430e5eb..2316f7d9b4eeb5 100644 --- a/test/fixtures/wpt/wasm/jsapi/prototypes.any.js +++ b/test/fixtures/wpt/wasm/jsapi/prototypes.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/wasm-module-builder.js diff --git a/test/fixtures/wpt/wasm/jsapi/table/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/table/WEB_FEATURES.yml new file mode 100644 index 00000000000000..d8e24fc0e8f503 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/table/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/table/assertions.js b/test/fixtures/wpt/wasm/jsapi/table/assertions.js index 19cc5c3b92d6fa..4fcd3517a6a869 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/assertions.js +++ b/test/fixtures/wpt/wasm/jsapi/table/assertions.js @@ -11,6 +11,7 @@ function assert_equal_to_array(table, expected, message) { assert_throws_js(RangeError, () => table.get(expected.length + 1), `${message}: table.get(${expected.length + 1} of ${expected.length})`); } +globalThis.assert_equal_to_array = assert_equal_to_array; function assert_Table(actual, expected) { assert_equals(Object.getPrototypeOf(actual), WebAssembly.Table.prototype, @@ -22,3 +23,4 @@ function assert_Table(actual, expected) { assert_equals(actual.get(i), null, `actual.get(${i})`); } } +globalThis.assert_Table = assert_Table; diff --git a/test/fixtures/wpt/wasm/jsapi/table/constructor-types.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/table/constructor-types.tentative.any.js index 99ca41b55a9152..b4015bdc6f7503 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/constructor-types.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/table/constructor-types.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/table/assertions.js @@ -17,4 +17,4 @@ test(() => { const argument = { "element": "anyfunc", "minimum": 5 }; const table = new WebAssembly.Table(argument); assert_Table(table, { "length": 5 }); -}, "Non-zero minimum"); \ No newline at end of file +}, "Non-zero minimum"); diff --git a/test/fixtures/wpt/wasm/jsapi/table/constructor.any.js b/test/fixtures/wpt/wasm/jsapi/table/constructor.any.js index 6d38d04e4f5050..1143bab72a26ce 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/constructor.any.js +++ b/test/fixtures/wpt/wasm/jsapi/table/constructor.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=/wasm/jsapi/assertions.js // META: script=/wasm/jsapi/table/assertions.js @@ -206,3 +206,8 @@ test(() => { assert_throws_js(TypeError, () => new WebAssembly.Table(argument, "cannot be used as a wasm function")); assert_throws_js(TypeError, () => new WebAssembly.Table(argument, 37)); }, "initialize anyfunc table with a bad default value"); + +test(() => { + assert_throws_js(RangeError, () => + new WebAssembly.Table({ "element": "anyfunc", "initial": 3, "maximum": 2 }, 37)); +}, "initialize anyfunc table with a bad default value and a bad descriptor"); diff --git a/test/fixtures/wpt/wasm/jsapi/table/get-set.any.js b/test/fixtures/wpt/wasm/jsapi/table/get-set.any.js index 9301057a533ed4..abe6fecac9860c 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/get-set.any.js +++ b/test/fixtures/wpt/wasm/jsapi/table/get-set.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=assertions.js diff --git a/test/fixtures/wpt/wasm/jsapi/table/grow.any.js b/test/fixtures/wpt/wasm/jsapi/table/grow.any.js index 520d24bf4bafbb..4038f1e64934b0 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/grow.any.js +++ b/test/fixtures/wpt/wasm/jsapi/table/grow.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/wasm-module-builder.js // META: script=assertions.js diff --git a/test/fixtures/wpt/wasm/jsapi/table/length.any.js b/test/fixtures/wpt/wasm/jsapi/table/length.any.js index a9ef095ded4458..0e6de3f83e1ecf 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/length.any.js +++ b/test/fixtures/wpt/wasm/jsapi/table/length.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm test(() => { const thisValues = [ diff --git a/test/fixtures/wpt/wasm/jsapi/table/toString.any.js b/test/fixtures/wpt/wasm/jsapi/table/toString.any.js index 8a09f2832c1d64..b5fb25b9c1e954 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/toString.any.js +++ b/test/fixtures/wpt/wasm/jsapi/table/toString.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm test(() => { const argument = { "element": "anyfunc", "initial": 0 }; diff --git a/test/fixtures/wpt/wasm/jsapi/table/type.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/table/type.tentative.any.js index ef1ceecb17d695..ef9bbc5aa4a228 100644 --- a/test/fixtures/wpt/wasm/jsapi/table/type.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/table/type.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function assert_type(argument) { diff --git a/test/fixtures/wpt/wasm/jsapi/tag/WEB_FEATURES.yml b/test/fixtures/wpt/wasm/jsapi/tag/WEB_FEATURES.yml new file mode 100644 index 00000000000000..ffbe372e8d6523 --- /dev/null +++ b/test/fixtures/wpt/wasm/jsapi/tag/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: wasm-exception-handling + files: "**" diff --git a/test/fixtures/wpt/wasm/jsapi/tag/constructor.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/tag/constructor.tentative.any.js index de63e7bf46d1e8..54edf8c8f59258 100644 --- a/test/fixtures/wpt/wasm/jsapi/tag/constructor.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/tag/constructor.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js test(() => { diff --git a/test/fixtures/wpt/wasm/jsapi/tag/toString.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/tag/toString.tentative.any.js index ad9a4ba152f4f8..76fff0feef063d 100644 --- a/test/fixtures/wpt/wasm/jsapi/tag/toString.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/tag/toString.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm test(() => { const argument = { parameters: [] }; diff --git a/test/fixtures/wpt/wasm/jsapi/tag/type.tentative.any.js b/test/fixtures/wpt/wasm/jsapi/tag/type.tentative.any.js index 9d2f0de1a00f20..58c96078bfe39d 100644 --- a/test/fixtures/wpt/wasm/jsapi/tag/type.tentative.any.js +++ b/test/fixtures/wpt/wasm/jsapi/tag/type.tentative.any.js @@ -1,4 +1,4 @@ -// META: global=window,dedicatedworker,jsshell +// META: global=window,dedicatedworker,jsshell,shadowrealm // META: script=/wasm/jsapi/assertions.js function assert_type(argument) { diff --git a/test/fixtures/wpt/wasm/jsapi/wasm-module-builder.js b/test/fixtures/wpt/wasm/jsapi/wasm-module-builder.js index 86d836a5a37245..e8c65ed8c12100 100644 --- a/test/fixtures/wpt/wasm/jsapi/wasm-module-builder.js +++ b/test/fixtures/wpt/wasm/jsapi/wasm-module-builder.js @@ -45,7 +45,7 @@ var kWasmV3 = 0; var kHeaderSize = 8; var kPageSize = 65536; -var kSpecMaxPages = 65535; +var kSpecMaxPages = 65536; var kMaxVarInt32Size = 5; var kMaxVarInt64Size = 10; @@ -74,6 +74,14 @@ let kLocalNamesCode = 2; let kWasmFunctionTypeForm = 0x60; let kWasmAnyFunctionTypeForm = 0x70; +let kWasmStructTypeForm = 0x5f; +let kWasmArrayTypeForm = 0x5e; +let kWasmSubtypeForm = 0x50; +let kWasmSubtypeFinalForm = 0x4f; +let kWasmRecursiveTypeGroupForm = 0x4e; + +let kNoSuperType = 0xFFFFFFFF; +globalThis.kNoSuperType = kNoSuperType; let kHasMaximumFlag = 1; let kSharedHasMaximumFlag = 3; @@ -97,8 +105,57 @@ let kWasmI64 = 0x7e; let kWasmF32 = 0x7d; let kWasmF64 = 0x7c; let kWasmS128 = 0x7b; -let kWasmAnyRef = 0x6f; -let kWasmAnyFunc = 0x70; + +// Packed storage types +let kWasmI8 = 0x78; +let kWasmI16 = 0x77; + +// These are defined as negative integers to distinguish them from positive type +// indices. +let kWasmNullFuncRef = -0x0d; +let kWasmNullExternRef = -0x0e; +let kWasmNullRef = -0x0f; +let kWasmFuncRef = -0x10; +let kWasmAnyFunc = kWasmFuncRef; // Alias named as in the JS API spec +let kWasmExternRef = -0x11; +let kWasmAnyRef = -0x12; +let kWasmEqRef = -0x13; +let kWasmI31Ref = -0x14; +let kWasmStructRef = -0x15; +let kWasmArrayRef = -0x16; + +// Use the positive-byte versions inside function bodies. +let kLeb128Mask = 0x7f; +let kFuncRefCode = kWasmFuncRef & kLeb128Mask; +let kAnyFuncCode = kFuncRefCode; // Alias named as in the JS API spec +let kExternRefCode = kWasmExternRef & kLeb128Mask; +let kAnyRefCode = kWasmAnyRef & kLeb128Mask; +let kEqRefCode = kWasmEqRef & kLeb128Mask; +let kI31RefCode = kWasmI31Ref & kLeb128Mask; +let kNullExternRefCode = kWasmNullExternRef & kLeb128Mask; +let kNullFuncRefCode = kWasmNullFuncRef & kLeb128Mask; +let kStructRefCode = kWasmStructRef & kLeb128Mask; +let kArrayRefCode = kWasmArrayRef & kLeb128Mask; +let kNullRefCode = kWasmNullRef & kLeb128Mask; + +let kWasmRefNull = 0x63; +let kWasmRef = 0x64; +function wasmRefNullType(heap_type) { + return {opcode: kWasmRefNull, heap_type: heap_type}; +} +function wasmRefType(heap_type) { + return {opcode: kWasmRef, heap_type: heap_type}; +} + +Object.assign(globalThis, { + kWasmStmt, kWasmI32, kWasmI64, kWasmF32, kWasmF64, kWasmS128, kWasmI8, + kWasmI16, kWasmNullFuncRef, kWasmNullExternRef, kWasmNullRef, kWasmFuncRef, + kWasmAnyFunc, kWasmExternRef, kWasmAnyRef, kWasmEqRef, kWasmI31Ref, + kWasmStructRef, kWasmArrayRef, kFuncRefCode, kAnyFuncCode, kExternRefCode, + kAnyRefCode, kEqRefCode, kI31RefCode, kNullExternRefCode, kNullFuncRefCode, + kStructRefCode, kArrayRefCode, kNullRefCode, kWasmRefNull, kWasmRef, + wasmRefNullType, wasmRefType +}); let kExternalFunction = 0; let kExternalTable = 1; @@ -106,6 +163,11 @@ let kExternalMemory = 2; let kExternalGlobal = 3; let kExternalTag = 4; +Object.assign(globalThis, { + kExternalFunction, kExternalTable, kExternalMemory, kExternalGlobal, + kExternalTag +}); + let kTableZero = 0; let kMemoryZero = 0; let kSegmentZero = 0; @@ -146,14 +208,14 @@ let kSig_v_f = makeSig([kWasmF32], []); let kSig_f_f = makeSig([kWasmF32], [kWasmF32]); let kSig_f_d = makeSig([kWasmF64], [kWasmF32]); let kSig_d_d = makeSig([kWasmF64], [kWasmF64]); -let kSig_r_r = makeSig([kWasmAnyRef], [kWasmAnyRef]); +let kSig_r_r = makeSig([kWasmExternRef], [kWasmExternRef]); let kSig_a_a = makeSig([kWasmAnyFunc], [kWasmAnyFunc]); -let kSig_i_r = makeSig([kWasmAnyRef], [kWasmI32]); -let kSig_v_r = makeSig([kWasmAnyRef], []); +let kSig_i_r = makeSig([kWasmExternRef], [kWasmI32]); +let kSig_v_r = makeSig([kWasmExternRef], []); let kSig_v_a = makeSig([kWasmAnyFunc], []); -let kSig_v_rr = makeSig([kWasmAnyRef, kWasmAnyRef], []); +let kSig_v_rr = makeSig([kWasmExternRef, kWasmExternRef], []); let kSig_v_aa = makeSig([kWasmAnyFunc, kWasmAnyFunc], []); -let kSig_r_v = makeSig([], [kWasmAnyRef]); +let kSig_r_v = makeSig([], [kWasmExternRef]); let kSig_a_v = makeSig([], [kWasmAnyFunc]); let kSig_a_i = makeSig([kWasmI32], [kWasmAnyFunc]); @@ -181,6 +243,17 @@ function makeSig_r_xx(r, x) { return makeSig([x, x], [r]); } +Object.assign(globalThis, { + kSig_i_i, kSig_l_l, kSig_i_l, kSig_i_ii, kSig_i_iii, kSig_v_iiii, kSig_f_ff, + kSig_d_dd, kSig_l_ll, kSig_i_dd, kSig_v_v, kSig_i_v, kSig_l_v, kSig_f_v, + kSig_d_v, kSig_v_i, kSig_v_ii, kSig_v_iii, kSig_v_l, kSig_v_d, kSig_v_dd, + kSig_v_ddi, kSig_ii_v, kSig_iii_v, kSig_ii_i, kSig_iii_i, kSig_ii_ii, + kSig_iii_ii, kSig_v_f, kSig_f_f, kSig_f_d, kSig_d_d, kSig_r_r, kSig_a_a, + kSig_i_r, kSig_v_r, kSig_v_a, kSig_v_rr, kSig_v_aa, kSig_r_v, kSig_a_v, + kSig_a_i, + makeSig, makeSig_v_x, makeSig_v_xx, makeSig_r_v, makeSig_r_x, makeSig_r_xx +}); + // Opcodes let kExprUnreachable = 0x00; let kExprNop = 0x01; @@ -374,10 +447,50 @@ let kExprRefIsNull = 0xd1; let kExprRefFunc = 0xd2; // Prefix opcodes +let kGCPrefix = 0xfb; let kNumericPrefix = 0xfc; let kSimdPrefix = 0xfd; let kAtomicPrefix = 0xfe; +// Use these for multi-byte instructions (opcode > 0x7F needing two LEB bytes): +function GCInstr(opcode) { + if (opcode <= 0x7F) return [kGCPrefix, opcode]; + return [kGCPrefix, 0x80 | (opcode & 0x7F), opcode >> 7]; +} + +// GC opcodes +let kExprStructNew = 0x00; +let kExprStructNewDefault = 0x01; +let kExprStructGet = 0x02; +let kExprStructGetS = 0x03; +let kExprStructGetU = 0x04; +let kExprStructSet = 0x05; +let kExprArrayNew = 0x06; +let kExprArrayNewDefault = 0x07; +let kExprArrayNewFixed = 0x08; +let kExprArrayNewData = 0x09; +let kExprArrayNewElem = 0x0a; +let kExprArrayGet = 0x0b; +let kExprArrayGetS = 0x0c; +let kExprArrayGetU = 0x0d; +let kExprArraySet = 0x0e; +let kExprArrayLen = 0x0f; +let kExprArrayFill = 0x10; +let kExprArrayCopy = 0x11; +let kExprArrayInitData = 0x12; +let kExprArrayInitElem = 0x13; +let kExprRefTest = 0x14; +let kExprRefTestNull = 0x15; +let kExprRefCast = 0x16; +let kExprRefCastNull = 0x17; +let kExprBrOnCast = 0x18; +let kExprBrOnCastFail = 0x19; +let kExprExternInternalize = 0x1a; +let kExprExternExternalize = 0x1b; +let kExprI31New = 0x1c; +let kExprI31GetS = 0x1d; +let kExprI31GetU = 0x1e; + // Numeric opcodes. let kExprMemoryInit = 0x08; let kExprDataDrop = 0x09; @@ -467,6 +580,89 @@ let kExprI32x4Eq = 0x2c; let kExprS1x4AllTrue = 0x75; let kExprF32x4Min = 0x9e; +Object.assign(globalThis, { + kExprUnreachable, kExprNop, kExprBlock, kExprLoop, kExprIf, kExprElse, + kExprTry, kExprCatch, kExprCatchAll, kExprThrow, kExprRethrow, kExprBrOnExn, + kExprEnd, kExprBr, kExprBrIf, kExprBrTable, kExprReturn, kExprCallFunction, + kExprCallIndirect, kExprReturnCall, kExprReturnCallIndirect, kExprDrop, + kExprSelect, kExprLocalGet, kExprLocalSet, kExprLocalTee, kExprGlobalGet, + kExprGlobalSet, kExprTableGet, kExprTableSet, kExprI32LoadMem, + kExprI64LoadMem, kExprF32LoadMem, kExprF64LoadMem, kExprI32LoadMem8S, + kExprI32LoadMem8U, kExprI32LoadMem16S, kExprI32LoadMem16U, kExprI64LoadMem8S, + kExprI64LoadMem8U, kExprI64LoadMem16S, kExprI64LoadMem16U, kExprI64LoadMem32S, + kExprI64LoadMem32U, kExprI32StoreMem, kExprI64StoreMem, kExprF32StoreMem, + kExprF64StoreMem, kExprI32StoreMem8, kExprI32StoreMem16, kExprI64StoreMem8, + kExprI64StoreMem16, kExprI64StoreMem32, kExprMemorySize, kExprMemoryGrow, + kExprI32Const, kExprI64Const, kExprF32Const, kExprF64Const, kExprI32Eqz, + kExprI32Eq, kExprI32Ne, kExprI32LtS, kExprI32LtU, kExprI32GtS, kExprI32GtU, + kExprI32LeS, kExprI32LeU, kExprI32GeS, kExprI32GeU, kExprI64Eqz, kExprI64Eq, + kExprI64Ne, kExprI64LtS, kExprI64LtU, kExprI64GtS, kExprI64GtU, kExprI64LeS, + kExprI64LeU, kExprI64GeS, kExprI64GeU, kExprF32Eq, kExprF32Ne, kExprF32Lt, + kExprF32Gt, kExprF32Le, kExprF32Ge, kExprF64Eq, kExprF64Ne, kExprF64Lt, + kExprF64Gt, kExprF64Le, kExprF64Ge, kExprI32Clz, kExprI32Ctz, kExprI32Popcnt, + kExprI32Add, kExprI32Sub, kExprI32Mul, kExprI32DivS, kExprI32DivU, + kExprI32RemS, kExprI32RemU, kExprI32And, kExprI32Ior, kExprI32Xor, + kExprI32Shl, kExprI32ShrS, kExprI32ShrU, kExprI32Rol, kExprI32Ror, + kExprI64Clz, kExprI64Ctz, kExprI64Popcnt, kExprI64Add, kExprI64Sub, + kExprI64Mul, kExprI64DivS, kExprI64DivU, kExprI64RemS, kExprI64RemU, + kExprI64And, kExprI64Ior, kExprI64Xor, kExprI64Shl, kExprI64ShrS, + kExprI64ShrU, kExprI64Rol, kExprI64Ror, kExprF32Abs, kExprF32Neg, + kExprF32Ceil, kExprF32Floor, kExprF32Trunc, kExprF32NearestInt, kExprF32Sqrt, + kExprF32Add, kExprF32Sub, kExprF32Mul, kExprF32Div, kExprF32Min, kExprF32Max, + kExprF32CopySign, kExprF64Abs, kExprF64Neg, kExprF64Ceil, kExprF64Floor, + kExprF64Trunc, kExprF64NearestInt, kExprF64Sqrt, kExprF64Add, kExprF64Sub, + kExprF64Mul, kExprF64Div, kExprF64Min, kExprF64Max, kExprF64CopySign, + kExprI32ConvertI64, kExprI32SConvertF32, kExprI32UConvertF32, + kExprI32SConvertF64, kExprI32UConvertF64, kExprI64SConvertI32, + kExprI64UConvertI32, kExprI64SConvertF32, kExprI64UConvertF32, + kExprI64SConvertF64, kExprI64UConvertF64, kExprF32SConvertI32, + kExprF32UConvertI32, kExprF32SConvertI64, kExprF32UConvertI64, + kExprF32ConvertF64, kExprF64SConvertI32, kExprF64UConvertI32, + kExprF64SConvertI64, kExprF64UConvertI64, kExprF64ConvertF32, + kExprI32ReinterpretF32, kExprI64ReinterpretF64, kExprF32ReinterpretI32, + kExprF64ReinterpretI64, kExprI32SExtendI8, kExprI32SExtendI16, + kExprI64SExtendI8, kExprI64SExtendI16, kExprI64SExtendI32, kExprRefNull, + kExprRefIsNull, kExprRefFunc, + GCInstr, + kExprStructNew, kExprStructNewDefault, kExprStructGet, kExprStructGetS, + kExprStructGetU, kExprStructSet, kExprArrayNew, kExprArrayNewDefault, + kExprArrayNewFixed, kExprArrayNewData, kExprArrayNewElem, kExprArrayGet, + kExprArrayGetS, kExprArrayGetU, kExprArraySet, kExprArrayLen, kExprArrayFill, + kExprArrayCopy, kExprArrayInitData, kExprArrayInitElem, kExprRefTest, + kExprRefTestNull, kExprRefCast, kExprRefCastNull, kExprBrOnCast, + kExprBrOnCastFail, kExprExternInternalize, kExprExternExternalize, + kExprI31New, kExprI31GetS, kExprI31GetU, + kExprMemoryInit, kExprDataDrop, kExprMemoryCopy, kExprMemoryFill, + kExprTableInit, kExprElemDrop, kExprTableCopy, kExprTableGrow, kExprTableSize, + kExprTableFill, + kExprAtomicNotify, kExprI32AtomicWait, kExprI64AtomicWait, kExprI32AtomicLoad, + kExprI32AtomicLoad8U, kExprI32AtomicLoad16U, kExprI32AtomicStore, + kExprI32AtomicStore8U, kExprI32AtomicStore16U, kExprI32AtomicAdd, + kExprI32AtomicAdd8U, kExprI32AtomicAdd16U, kExprI32AtomicSub, + kExprI32AtomicSub8U, kExprI32AtomicSub16U, kExprI32AtomicAnd, + kExprI32AtomicAnd8U, kExprI32AtomicAnd16U, kExprI32AtomicOr, + kExprI32AtomicOr8U, kExprI32AtomicOr16U, kExprI32AtomicXor, + kExprI32AtomicXor8U, kExprI32AtomicXor16U, kExprI32AtomicExchange, + kExprI32AtomicExchange8U, kExprI32AtomicExchange16U, + kExprI32AtomicCompareExchange, kExprI32AtomicCompareExchange8U, + kExprI32AtomicCompareExchange16U, kExprI64AtomicLoad, kExprI64AtomicLoad8U, + kExprI64AtomicLoad16U, kExprI64AtomicLoad32U, kExprI64AtomicStore, + kExprI64AtomicStore8U, kExprI64AtomicStore16U, kExprI64AtomicStore32U, + kExprI64AtomicAdd, kExprI64AtomicAdd8U, kExprI64AtomicAdd16U, + kExprI64AtomicAdd32U, kExprI64AtomicSub, kExprI64AtomicSub8U, + kExprI64AtomicSub16U, kExprI64AtomicSub32U, kExprI64AtomicAnd, + kExprI64AtomicAnd8U, kExprI64AtomicAnd16U, kExprI64AtomicAnd32U, + kExprI64AtomicOr, kExprI64AtomicOr8U, kExprI64AtomicOr16U, + kExprI64AtomicOr32U, kExprI64AtomicXor, kExprI64AtomicXor8U, + kExprI64AtomicXor16U, kExprI64AtomicXor32U, kExprI64AtomicExchange, + kExprI64AtomicExchange8U, kExprI64AtomicExchange16U, + kExprI64AtomicExchange32U, kExprI64AtomicCompareExchange, + kExprI64AtomicCompareExchange8U, kExprI64AtomicCompareExchange16U, + kExprI64AtomicCompareExchange32U, + kExprS128LoadMem, kExprS128StoreMem, kExprI32x4Splat, kExprI32x4Eq, + kExprS1x4AllTrue, kExprF32x4Min +}); + class Binary { constructor() { this.length = 0; @@ -554,6 +750,25 @@ class Binary { } } + emit_heap_type(heap_type) { + this.emit_bytes(wasmSignedLeb(heap_type, kMaxVarInt32Size)); + } + + emit_type(type) { + if ((typeof type) == 'number') { + this.emit_u8(type >= 0 ? type : type & kLeb128Mask); + } else { + this.emit_u8(type.opcode); + if ('depth' in type) this.emit_u8(type.depth); + this.emit_heap_type(type.heap_type); + } + } + + emit_init_expr(expr) { + this.emit_bytes(expr); + this.emit_u8(kExprEnd); + } + emit_header() { this.emit_bytes([ kWasmH0, kWasmH1, kWasmH2, kWasmH3, kWasmV0, kWasmV1, kWasmV2, kWasmV3 @@ -644,11 +859,11 @@ class WasmFunctionBuilder { } class WasmGlobalBuilder { - constructor(module, type, mutable) { + constructor(module, type, mutable, init) { this.module = module; this.type = type; this.mutable = mutable; - this.init = 0; + this.init = init; } exportAs(name) { @@ -658,13 +873,24 @@ class WasmGlobalBuilder { } } +function checkExpr(expr) { + for (let b of expr) { + if (typeof b !== 'number' || (b & (~0xFF)) !== 0) { + throw new Error( + 'invalid body (entries must be 8 bit numbers): ' + expr); + } + } +} + class WasmTableBuilder { - constructor(module, type, initial_size, max_size) { + constructor(module, type, initial_size, max_size, init_expr) { this.module = module; this.type = type; this.initial_size = initial_size; this.has_max = max_size != undefined; this.max_size = max_size; + this.init_expr = init_expr; + this.has_init = init_expr !== undefined; } exportAs(name) { @@ -674,6 +900,35 @@ class WasmTableBuilder { } } +function makeField(type, mutability) { + if ((typeof mutability) != 'boolean') { + throw new Error('field mutability must be boolean'); + } + return {type: type, mutability: mutability}; +} + +class WasmStruct { + constructor(fields, is_final, supertype_idx) { + if (!Array.isArray(fields)) { + throw new Error('struct fields must be an array'); + } + this.fields = fields; + this.type_form = kWasmStructTypeForm; + this.is_final = is_final; + this.supertype = supertype_idx; + } +} + +class WasmArray { + constructor(type, mutability, is_final, supertype_idx) { + this.type = type; + this.mutability = mutability; + this.type_form = kWasmArrayTypeForm; + this.is_final = is_final; + this.supertype = supertype_idx; + } +} + class WasmModuleBuilder { constructor() { this.types = []; @@ -686,6 +941,7 @@ class WasmModuleBuilder { this.element_segments = []; this.data_segments = []; this.explicit = []; + this.rec_groups = []; this.num_imported_funcs = 0; this.num_imported_globals = 0; this.num_imported_tables = 0; @@ -728,25 +984,65 @@ class WasmModuleBuilder { this.explicit.push(this.createCustomSection(name, bytes)); } - addType(type) { - this.types.push(type); - var pl = type.params.length; // should have params - var rl = type.results.length; // should have results + // We use {is_final = true} so that the MVP syntax is generated for + // signatures. + addType(type, supertype_idx = kNoSuperType, is_final = true) { + var pl = type.params.length; // should have params + var rl = type.results.length; // should have results + var type_copy = {params: type.params, results: type.results, + is_final: is_final, supertype: supertype_idx}; + this.types.push(type_copy); return this.types.length - 1; } - addGlobal(local_type, mutable) { - let glob = new WasmGlobalBuilder(this, local_type, mutable); + addStruct(fields, supertype_idx = kNoSuperType, is_final = false) { + this.types.push(new WasmStruct(fields, is_final, supertype_idx)); + return this.types.length - 1; + } + + addArray(type, mutability, supertype_idx = kNoSuperType, is_final = false) { + this.types.push(new WasmArray(type, mutability, is_final, supertype_idx)); + return this.types.length - 1; + } + + static defaultFor(type) { + switch (type) { + case kWasmI32: + return wasmI32Const(0); + case kWasmI64: + return wasmI64Const(0); + case kWasmF32: + return wasmF32Const(0.0); + case kWasmF64: + return wasmF64Const(0.0); + case kWasmS128: + return [kSimdPrefix, kExprS128Const, ...(new Array(16).fill(0))]; + default: + if ((typeof type) != 'number' && type.opcode != kWasmRefNull) { + throw new Error("Non-defaultable type"); + } + let heap_type = (typeof type) == 'number' ? type : type.heap_type; + return [kExprRefNull, ...wasmSignedLeb(heap_type, kMaxVarInt32Size)]; + } + } + + addGlobal(type, mutable, init) { + if (init === undefined) init = WasmModuleBuilder.defaultFor(type); + checkExpr(init); + let glob = new WasmGlobalBuilder(this, type, mutable, init); glob.index = this.globals.length + this.num_imported_globals; this.globals.push(glob); return glob; } - addTable(type, initial_size, max_size = undefined) { - if (type != kWasmAnyRef && type != kWasmAnyFunc) { - throw new Error('Tables must be of type kWasmAnyRef or kWasmAnyFunc'); + addTable(type, initial_size, max_size = undefined, init_expr = undefined) { + if (type == kWasmI32 || type == kWasmI64 || type == kWasmF32 || + type == kWasmF64 || type == kWasmS128 || type == kWasmStmt) { + throw new Error('Tables must be of a reference type'); } - let table = new WasmTableBuilder(this, type, initial_size, max_size); + if (init_expr != undefined) checkExpr(init_expr); + let table = new WasmTableBuilder( + this, type, initial_size, max_size, init_expr); table.index = this.tables.length + this.num_imported_tables; this.tables.push(table); return table; @@ -754,9 +1050,9 @@ class WasmModuleBuilder { addTag(type) { let type_index = (typeof type) == "number" ? type : this.addType(type); - let except_index = this.tags.length + this.num_imported_tags; + let tag_index = this.tags.length + this.num_imported_tags; this.tags.push(type_index); - return except_index; + return tag_index; } addFunction(name, type) { @@ -877,6 +1173,21 @@ class WasmModuleBuilder { return this; } + startRecGroup() { + this.rec_groups.push({start: this.types.length, size: 0}); + } + + endRecGroup() { + if (this.rec_groups.length == 0) { + throw new Error("Did not start a recursive group before ending one") + } + let last_element = this.rec_groups[this.rec_groups.length - 1] + if (last_element.size != 0) { + throw new Error("Did not start a recursive group before ending one") + } + last_element.size = this.types.length - last_element.start; + } + setName(name) { this.name = name; return this; @@ -891,18 +1202,55 @@ class WasmModuleBuilder { // Add type section if (wasm.types.length > 0) { - if (debug) print("emitting types @ " + binary.length); + if (debug) print('emitting types @ ' + binary.length); binary.emit_section(kTypeSectionCode, section => { - section.emit_u32v(wasm.types.length); - for (let type of wasm.types) { - section.emit_u8(kWasmFunctionTypeForm); - section.emit_u32v(type.params.length); - for (let param of type.params) { - section.emit_u8(param); + let length_with_groups = wasm.types.length; + for (let group of wasm.rec_groups) { + length_with_groups -= group.size - 1; + } + section.emit_u32v(length_with_groups); + + let rec_group_index = 0; + + for (let i = 0; i < wasm.types.length; i++) { + if (rec_group_index < wasm.rec_groups.length && + wasm.rec_groups[rec_group_index].start == i) { + section.emit_u8(kWasmRecursiveTypeGroupForm); + section.emit_u32v(wasm.rec_groups[rec_group_index].size); + rec_group_index++; + } + + let type = wasm.types[i]; + if (type.supertype != kNoSuperType) { + section.emit_u8(type.is_final ? kWasmSubtypeFinalForm + : kWasmSubtypeForm); + section.emit_u8(1); // supertype count + section.emit_u32v(type.supertype); + } else if (!type.is_final) { + section.emit_u8(kWasmSubtypeForm); + section.emit_u8(0); // no supertypes } - section.emit_u32v(type.results.length); - for (let result of type.results) { - section.emit_u8(result); + if (type instanceof WasmStruct) { + section.emit_u8(kWasmStructTypeForm); + section.emit_u32v(type.fields.length); + for (let field of type.fields) { + section.emit_type(field.type); + section.emit_u8(field.mutability ? 1 : 0); + } + } else if (type instanceof WasmArray) { + section.emit_u8(kWasmArrayTypeForm); + section.emit_type(type.type); + section.emit_u8(type.mutability ? 1 : 0); + } else { + section.emit_u8(kWasmFunctionTypeForm); + section.emit_u32v(type.params.length); + for (let param of type.params) { + section.emit_type(param); + } + section.emit_u32v(type.results.length); + for (let result of type.results) { + section.emit_type(result); + } } } }); @@ -920,7 +1268,7 @@ class WasmModuleBuilder { if (imp.kind == kExternalFunction) { section.emit_u32v(imp.type); } else if (imp.kind == kExternalGlobal) { - section.emit_u32v(imp.type); + section.emit_type(imp.type); section.emit_u8(imp.mutable); } else if (imp.kind == kExternalMemory) { var has_max = (typeof imp.maximum) != "undefined"; @@ -933,7 +1281,7 @@ class WasmModuleBuilder { section.emit_u32v(imp.initial); // initial if (has_max) section.emit_u32v(imp.maximum); // maximum } else if (imp.kind == kExternalTable) { - section.emit_u8(imp.type); + section.emit_type(imp.type); var has_max = (typeof imp.maximum) != "undefined"; section.emit_u8(has_max ? 1 : 0); // flags section.emit_u32v(imp.initial); // initial @@ -965,10 +1313,11 @@ class WasmModuleBuilder { binary.emit_section(kTableSectionCode, section => { section.emit_u32v(wasm.tables.length); for (let table of wasm.tables) { - section.emit_u8(table.type); + section.emit_type(table.type); section.emit_u8(table.has_max); section.emit_u32v(table.initial_size); if (table.has_max) section.emit_u32v(table.max_size); + if (table.has_init) section.emit_init_expr(table.init_expr); } }); } @@ -997,41 +1346,9 @@ class WasmModuleBuilder { binary.emit_section(kGlobalSectionCode, section => { section.emit_u32v(wasm.globals.length); for (let global of wasm.globals) { - section.emit_u8(global.type); + section.emit_type(global.type); section.emit_u8(global.mutable); - if ((typeof global.init_index) == "undefined") { - // Emit a constant initializer. - switch (global.type) { - case kWasmI32: - section.emit_u8(kExprI32Const); - section.emit_u32v(global.init); - break; - case kWasmI64: - section.emit_u8(kExprI64Const); - section.emit_u64v(global.init); - break; - case kWasmF32: - section.emit_bytes(wasmF32Const(global.init)); - break; - case kWasmF64: - section.emit_bytes(wasmF64Const(global.init)); - break; - case kWasmAnyFunc: - case kWasmAnyRef: - if (global.function_index !== undefined) { - section.emit_u8(kExprRefFunc); - section.emit_u32v(global.function_index); - } else { - section.emit_u8(kExprRefNull); - } - break; - } - } else { - // Emit a global-index initializer. - section.emit_u8(kExprGlobalGet); - section.emit_u32v(global.init_index); - } - section.emit_u8(kExprEnd); // end of init expression + section.emit_init_expr(global.init); } }); } @@ -1161,7 +1478,7 @@ class WasmModuleBuilder { local_decls.push({count: l.s128_count, type: kWasmS128}); } if (l.anyref_count > 0) { - local_decls.push({count: l.anyref_count, type: kWasmAnyRef}); + local_decls.push({count: l.anyref_count, type: kWasmExternRef}); } if (l.anyfunc_count > 0) { local_decls.push({count: l.anyfunc_count, type: kWasmAnyFunc}); @@ -1171,7 +1488,7 @@ class WasmModuleBuilder { header.emit_u32v(local_decls.length); for (let decl of local_decls) { header.emit_u32v(decl.count); - header.emit_u8(decl.type); + header.emit_type(decl.type); } section.emit_u32v(header.length + func.body.length); @@ -1284,6 +1601,7 @@ class WasmModuleBuilder { return new WebAssembly.Module(this.toBuffer(debug)); } } +globalThis.WasmModuleBuilder = WasmModuleBuilder; function wasmSignedLeb(val, max_len = 5) { let res = []; @@ -1300,10 +1618,12 @@ function wasmSignedLeb(val, max_len = 5) { throw new Error( 'Leb value <' + val + '> exceeds maximum length of ' + max_len); } +globalThis.wasmSignedLeb = wasmSignedLeb; function wasmI32Const(val) { return [kExprI32Const, ...wasmSignedLeb(val, 5)]; } +globalThis.wasmI32Const = wasmI32Const; function wasmF32Const(f) { // Write in little-endian order at offset 0. @@ -1312,6 +1632,7 @@ function wasmF32Const(f) { kExprF32Const, byte_view[0], byte_view[1], byte_view[2], byte_view[3] ]; } +globalThis.wasmI32Const = wasmI32Const; function wasmF64Const(f) { // Write in little-endian order at offset 0. @@ -1321,3 +1642,4 @@ function wasmF64Const(f) { byte_view[3], byte_view[4], byte_view[5], byte_view[6], byte_view[7] ]; } +globalThis.wasmF64Const = wasmF64Const; diff --git a/test/fixtures/wpt/wasm/resources/load_wasm.js b/test/fixtures/wpt/wasm/resources/load_wasm.js new file mode 100644 index 00000000000000..7f280ac032f40a --- /dev/null +++ b/test/fixtures/wpt/wasm/resources/load_wasm.js @@ -0,0 +1,12 @@ +// Copyright 2016 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +function createWasmModule() { + return fetch('/wasm/incrementer.wasm') + .then(response => { + if (!response.ok) throw new Error(response.statusText); + return response.arrayBuffer(); + }) + .then(WebAssembly.compile); +} diff --git a/test/wpt/status/wasm/jsapi.json b/test/wpt/status/wasm/jsapi.json new file mode 100644 index 00000000000000..4a20da7b2cf41c --- /dev/null +++ b/test/wpt/status/wasm/jsapi.json @@ -0,0 +1,96 @@ +{ + "esm-integration/global-exports-live-bindings.tentative.any.js": { + "skip": "Live bindings unsupported pending V8 WebAssemblyModuleRecord" + }, + "esm-integration/namespace-instance.tentative.any.js": { + "skip": "pending https://github.com/nodejs/node/pull/59024" + }, + "esm-integration/v128-tdz.tentative.any.js": { + "skip": "v128 undefined Wasm bindings not yet supported in V8" + }, + "global/value-get-set.any.js": { + "fail": { + "expected": [ + "Calling setter without argument" + ] + } + }, + "memory/to-fixed-length-buffer-shared.any.js": { + "fail": { + "expected": [ + "toFixedLengthBuffer caching behavior" + ] + } + }, + "memory/to-fixed-length-buffer.any.js": { + "fail": { + "expected": [ + "API surface", + "toFixedLengthBuffer caching behavior" + ] + } + }, + "memory/to-resizable-buffer-shared.any.js": { + "fail": { + "expected": [ + "toResizableBuffer caching behavior", + "toResizableBuffer max size", + "Resizing a Memory's resizable buffer" + ] + } + }, + "memory/to-resizable-buffer.any.js": { + "fail": { + "expected": [ + "API surface", + "toResizableBuffer caching behavior", + "toResizableBuffer max size", + "Resizing a Memory's resizable buffer", + "Resizable buffers from Memory cannot be detached by JS" + ] + } + }, + "module/moduleSource.tentative.any.js": { + "fail": { + "expected": [ + "AbstractModuleSourceProto toStringTag brand check" + ] + } + }, + "exception/getArg.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "function/call.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "function/constructor.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "function/table.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "function/type.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "global/type.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "idlharness.any.js": { + "skip": "track - still tentative / unsupported" + }, + "memory/constructor-types.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "memory/type.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "table/constructor-types.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "table/type.tentative.any.js": { + "skip": "track - still tentative / unsupported" + }, + "tag/type.tentative.any.js": { + "skip": "track - still tentative / unsupported" + } +} diff --git a/test/wpt/test-wasm-jsapi.mjs b/test/wpt/test-wasm-jsapi.mjs new file mode 100644 index 00000000000000..4e53da5bdd887e --- /dev/null +++ b/test/wpt/test-wasm-jsapi.mjs @@ -0,0 +1,22 @@ +// Flags: --experimental-wasm-modules +import * as fixtures from '../common/fixtures.mjs'; +import { ok } from 'node:assert'; +import { WPTRunner } from '../common/wpt.js'; + +// Verify we have Wasm SIMD support by importing a Wasm with SIMD +// since Wasm SIMD is not supported on older architectures such as IBM Power8. +let supportsSimd = false; +try { + await import(fixtures.fileURL('es-modules/globals.wasm')); + supportsSimd = true; +} catch (e) { + ok(e instanceof WebAssembly.CompileError); + ok(e.message.includes('SIMD unsupported')); +} + +if (supportsSimd) { + const runner = new WPTRunner('wasm/jsapi'); + runner.setFlags(['--experimental-wasm-modules']); + + runner.runJsTests(); +} From cb78a7f4840059e22452a79bebeda626b511bcaa Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 1 Apr 2026 03:04:24 +0800 Subject: [PATCH 5/7] diagnostics_channel: add BoundedChannel and scopes PR-URL: https://github.com/nodejs/node/pull/61680 Reviewed-By: James M Snell --- doc/api/diagnostics_channel.md | 397 ++++++++++++++++++ lib/diagnostics_channel.js | 391 ++++++++++++----- ...nel-bounded-channel-run-transform-error.js | 66 +++ ...diagnostics-channel-bounded-channel-run.js | 125 ++++++ ...ics-channel-bounded-channel-scope-error.js | 90 ++++ ...cs-channel-bounded-channel-scope-nested.js | 257 ++++++++++++ ...l-bounded-channel-scope-transform-error.js | 66 +++ ...agnostics-channel-bounded-channel-scope.js | 206 +++++++++ ...est-diagnostics-channel-bounded-channel.js | 105 +++++ ...hannel-run-stores-scope-transform-error.js | 57 +++ ...st-diagnostics-channel-run-stores-scope.js | 206 +++++++++ ...nnel-tracing-channel-promise-run-stores.js | 4 +- 12 files changed, 1863 insertions(+), 107 deletions(-) create mode 100644 test/parallel/test-diagnostics-channel-bounded-channel-run-transform-error.js create mode 100644 test/parallel/test-diagnostics-channel-bounded-channel-run.js create mode 100644 test/parallel/test-diagnostics-channel-bounded-channel-scope-error.js create mode 100644 test/parallel/test-diagnostics-channel-bounded-channel-scope-nested.js create mode 100644 test/parallel/test-diagnostics-channel-bounded-channel-scope-transform-error.js create mode 100644 test/parallel/test-diagnostics-channel-bounded-channel-scope.js create mode 100644 test/parallel/test-diagnostics-channel-bounded-channel.js create mode 100644 test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js create mode 100644 test/parallel/test-diagnostics-channel-run-stores-scope.js diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index 1c5e2b6535adbf..d0916abbe376f5 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -282,6 +282,53 @@ const channelsByCollection = diagnostics_channel.tracingChannel({ }); ``` +#### `diagnostics_channel.boundedChannel(nameOrChannels)` + + + +> Stability: 1 - Experimental + +* `nameOrChannels` {string|BoundedChannel} Channel name or + object containing all the [BoundedChannel Channels][] +* Returns: {BoundedChannel} Collection of channels to trace with + +Creates a [`BoundedChannel`][] wrapper for the given channels. If a name is +given, the corresponding channels will be created in the form of +`tracing:${name}:${eventType}` where `eventType` is `start` or `end`. + +A `BoundedChannel` is a simplified version of [`TracingChannel`][] that only +traces synchronous operations. It only has `start` and `end` events, without +`asyncStart`, `asyncEnd`, or `error` events, making it suitable for tracing +operations that don't involve asynchronous continuations or error handling. + +```mjs +import { boundedChannel, channel } from 'node:diagnostics_channel'; + +const wc = boundedChannel('my-operation'); + +// or... + +const wc2 = boundedChannel({ + start: channel('tracing:my-operation:start'), + end: channel('tracing:my-operation:end'), +}); +``` + +```cjs +const { boundedChannel, channel } = require('node:diagnostics_channel'); + +const wc = boundedChannel('my-operation'); + +// or... + +const wc2 = boundedChannel({ + start: channel('tracing:my-operation:start'), + end: channel('tracing:my-operation:end'), +}); +``` + ### Class: `Channel` + +> Stability: 1 - Experimental + +* `data` {any} Message to bind to stores +* Returns: {RunStoresScope} Disposable scope object + +Creates a disposable scope that binds the given data to any AsyncLocalStorage +instances bound to the channel and publishes it to subscribers. The scope +automatically restores the previous storage contexts when disposed. + +This method enables the use of JavaScript's explicit resource management +(`using` syntax with `Symbol.dispose`) to manage store contexts without +closure wrapping. + +```mjs +import { channel } from 'node:diagnostics_channel'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +const store = new AsyncLocalStorage(); +const ch = channel('my-channel'); + +ch.bindStore(store, (message) => { + return { ...message, timestamp: Date.now() }; +}); + +{ + using scope = ch.withStoreScope({ request: 'data' }); + // Store is entered, data is published + console.log(store.getStore()); // { request: 'data', timestamp: ... } +} +// Store is automatically restored on scope exit +``` + +```cjs +const { channel } = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +const store = new AsyncLocalStorage(); +const ch = channel('my-channel'); + +ch.bindStore(store, (message) => { + return { ...message, timestamp: Date.now() }; +}); + +{ + using scope = ch.withStoreScope({ request: 'data' }); + // Store is entered, data is published + console.log(store.getStore()); // { request: 'data', timestamp: ... } +} +// Store is automatically restored on scope exit +``` + +### Class: `RunStoresScope` + + + +> Stability: 1 - Experimental + +The class `RunStoresScope` represents a disposable scope created by +[`channel.withStoreScope(data)`][]. It manages the lifecycle of store +contexts and ensures they are properly restored when the scope exits. + +The scope must be used with the `using` syntax to ensure proper disposal. + ### Class: `TracingChannel` + +> Stability: 1 - Experimental + +The class `BoundedChannel` is a simplified version of [`TracingChannel`][] that +only traces synchronous operations. It consists of two channels (`start` and +`end`) instead of five, omitting the `asyncStart`, `asyncEnd`, and `error` +events. This makes it suitable for tracing operations that don't involve +asynchronous continuations or error handling. + +Like `TracingChannel`, it is recommended to create and reuse a single +`BoundedChannel` at the top-level of the file rather than creating them +dynamically. + +#### `boundedChannel.hasSubscribers` + + + +* Returns: {boolean} `true` if any of the individual channels has a subscriber, + `false` if not. + +Check if any of the `start` or `end` channels have subscribers. + +```mjs +import { boundedChannel } from 'node:diagnostics_channel'; + +const wc = boundedChannel('my-operation'); + +if (wc.hasSubscribers) { + // There are subscribers, perform traced operation +} +``` + +```cjs +const { boundedChannel } = require('node:diagnostics_channel'); + +const wc = boundedChannel('my-operation'); + +if (wc.hasSubscribers) { + // There are subscribers, perform traced operation +} +``` + +#### `boundedChannel.subscribe(handlers)` + + + +* `handlers` {Object} Set of channel subscribers + * `start` {Function} The start event subscriber + * `end` {Function} The end event subscriber + +Subscribe to the bounded channel events. This is equivalent to calling +[`channel.subscribe(onMessage)`][] on each channel individually. + +```mjs +import { boundedChannel } from 'node:diagnostics_channel'; + +const wc = boundedChannel('my-operation'); + +wc.subscribe({ + start(message) { + // Handle start + }, + end(message) { + // Handle end + }, +}); +``` + +```cjs +const { boundedChannel } = require('node:diagnostics_channel'); + +const wc = boundedChannel('my-operation'); + +wc.subscribe({ + start(message) { + // Handle start + }, + end(message) { + // Handle end + }, +}); +``` + +#### `boundedChannel.unsubscribe(handlers)` + + + +* `handlers` {Object} Set of channel subscribers + * `start` {Function} The start event subscriber + * `end` {Function} The end event subscriber +* Returns: {boolean} `true` if all handlers were successfully unsubscribed, + `false` otherwise. + +Unsubscribe from the bounded channel events. This is equivalent to calling +[`channel.unsubscribe(onMessage)`][] on each channel individually. + +```mjs +import { boundedChannel } from 'node:diagnostics_channel'; + +const wc = boundedChannel('my-operation'); + +const handlers = { + start(message) {}, + end(message) {}, +}; + +wc.subscribe(handlers); +wc.unsubscribe(handlers); +``` + +```cjs +const { boundedChannel } = require('node:diagnostics_channel'); + +const wc = boundedChannel('my-operation'); + +const handlers = { + start(message) {}, + end(message) {}, +}; + +wc.subscribe(handlers); +wc.unsubscribe(handlers); +``` + +#### `boundedChannel.run(context, fn[, thisArg[, ...args]])` + + + +* `context` {Object} Shared object to correlate events through +* `fn` {Function} Function to wrap a trace around +* `thisArg` {any} The receiver to be used for the function call +* `...args` {any} Optional arguments to pass to the function +* Returns: {any} The return value of the given function + +Trace a synchronous function call. This will produce a `start` event and `end` +event around the execution. This runs the given function using +[`channel.runStores(context, ...)`][] on the `start` channel which ensures all +events have any bound stores set to match this trace context. + +```mjs +import { boundedChannel } from 'node:diagnostics_channel'; + +const wc = boundedChannel('my-operation'); + +const result = wc.run({ operationId: '123' }, () => { + // Perform operation + return 42; +}); +``` + +```cjs +const { boundedChannel } = require('node:diagnostics_channel'); + +const wc = boundedChannel('my-operation'); + +const result = wc.run({ operationId: '123' }, () => { + // Perform operation + return 42; +}); +``` + +#### `boundedChannel.withScope([context])` + + + +* `context` {Object} Shared object to correlate events through +* Returns: {BoundedChannelScope} Disposable scope object + +Create a disposable scope for tracing a synchronous operation using JavaScript's +explicit resource management (`using` syntax). The scope automatically publishes +`start` and `end` events, enters bound stores, and handles cleanup when disposed. + +```mjs +import { boundedChannel } from 'node:diagnostics_channel'; + +const wc = boundedChannel('my-operation'); + +const context = { operationId: '123' }; +{ + using scope = wc.withScope(context); + // Stores are entered, start event is published + + // Perform work and set result on context + context.result = 42; +} +// End event is published, stores are restored automatically +``` + +```cjs +const { boundedChannel } = require('node:diagnostics_channel'); + +const wc = boundedChannel('my-operation'); + +const context = { operationId: '123' }; +{ + using scope = wc.withScope(context); + // Stores are entered, start event is published + + // Perform work and set result on context + context.result = 42; +} +// End event is published, stores are restored automatically +``` + +### Class: `BoundedChannelScope` + + + +> Stability: 1 - Experimental + +The class `BoundedChannelScope` represents a disposable scope created by +[`boundedChannel.withScope(context)`][]. It manages the lifecycle of a traced +operation, automatically publishing events and managing store contexts. + +The scope must be used with the `using` syntax to ensure proper disposal. + +```mjs +import { boundedChannel } from 'node:diagnostics_channel'; + +const wc = boundedChannel('my-operation'); + +const context = {}; +{ + using scope = wc.withScope(context); + // Start event is published, stores are entered + context.result = performOperation(); + // End event is automatically published at end of block +} +``` + +```cjs +const { boundedChannel } = require('node:diagnostics_channel'); + +const wc = boundedChannel('my-operation'); + +const context = {}; +{ + using scope = wc.withScope(context); + // Start event is published, stores are entered + context.result = performOperation(); + // End event is automatically published at end of block +} +``` + +### BoundedChannel Channels + +A `BoundedChannel` consists of two diagnostics channels representing the +lifecycle of a scope created with the `using` syntax: + +* `tracing:${name}:start` - Published when the `using` statement executes (scope creation) +* `tracing:${name}:end` - Published when exiting the block (scope disposal) + +When using the `using` syntax with \[`boundedChannel.withScope([context])`]\[], the `start` +event is published immediately when the statement executes, and the `end` event +is automatically published when disposal occurs at the end of the block. All +events share the same context object, which can be extended with additional +properties like `result` during scope execution. + ### TracingChannel Channels A TracingChannel is a collection of several diagnostics\_channels representing @@ -1518,15 +1911,19 @@ added: v16.18.0 Emitted when a new thread is created. +[BoundedChannel Channels]: #boundedchannel-channels [TracingChannel Channels]: #tracingchannel-channels [`'uncaughtException'`]: process.md#event-uncaughtexception +[`BoundedChannel`]: #class-boundedchannel [`TracingChannel`]: #class-tracingchannel [`asyncEnd` event]: #asyncendevent [`asyncStart` event]: #asyncstartevent +[`boundedChannel.withScope(context)`]: #boundedchannelwithscopecontext [`channel.bindStore(store)`]: #channelbindstorestore-transform [`channel.runStores(context, ...)`]: #channelrunstorescontext-fn-thisarg-args [`channel.subscribe(onMessage)`]: #channelsubscribeonmessage [`channel.unsubscribe(onMessage)`]: #channelunsubscribeonmessage +[`channel.withStoreScope(data)`]: #channelwithstorescopedata [`child_process.spawn()`]: child_process.md#child_processspawncommand-args-options [`diagnostics_channel.channel(name)`]: #diagnostics_channelchannelname [`diagnostics_channel.subscribe(name, onMessage)`]: #diagnostics_channelsubscribename-onmessage diff --git a/lib/diagnostics_channel.js b/lib/diagnostics_channel.js index cceadafbd84d3a..8d2d374dc8e6ae 100644 --- a/lib/diagnostics_channel.js +++ b/lib/diagnostics_channel.js @@ -10,9 +10,12 @@ const { ObjectDefineProperty, ObjectGetPrototypeOf, ObjectSetPrototypeOf, + PromisePrototypeThen, + PromiseReject, ReflectApply, SafeFinalizationRegistry, SafeMap, + SymbolDispose, SymbolHasInstance, } = primordials; @@ -31,6 +34,7 @@ const dc_binding = internalBinding('diagnostics_channel'); const { subscribers: subscriberCounts } = dc_binding; const { WeakReference } = require('internal/util'); +const { isPromise } = require('internal/util/types'); // Can't delete when weakref count reaches 0 as it could increment again. // Only GC can be used as a valid time to clean up the channels map. @@ -80,24 +84,45 @@ function maybeMarkInactive(channel) { } } -function defaultTransform(data) { - return data; -} +class RunStoresScope { + #stack; + + constructor(activeChannel, data) { + // eslint-disable-next-line no-restricted-globals + using stack = new DisposableStack(); + + // Enter stores using withScope + if (activeChannel._stores) { + for (const entry of activeChannel._stores.entries()) { + const store = entry[0]; + const transform = entry[1]; + + let newContext = data; + if (transform) { + try { + newContext = transform(data); + } catch (err) { + process.nextTick(() => { + triggerUncaughtException(err, false); + }); + continue; + } + } -function wrapStoreRun(store, data, next, transform = defaultTransform) { - return () => { - let context; - try { - context = transform(data); - } catch (err) { - process.nextTick(() => { - triggerUncaughtException(err, false); - }); - return next(); + stack.use(store.withScope(newContext)); + } } - return store.run(context, next); - }; + // Publish data + activeChannel.publish(data); + + // Transfer ownership of the stack + this.#stack = stack.move(); + } + + [SymbolDispose]() { + this.#stack[SymbolDispose](); + } } // TODO(qard): should there be a C++ channel interface? @@ -167,19 +192,14 @@ class ActiveChannel { } } - runStores(data, fn, thisArg, ...args) { - let run = () => { - this.publish(data); - return ReflectApply(fn, thisArg, args); - }; - - for (const entry of this._stores.entries()) { - const store = entry[0]; - const transform = entry[1]; - run = wrapStoreRun(store, data, run, transform); - } + withStoreScope(data) { + return new RunStoresScope(this, data); + } - return run(); + runStores(data, fn, thisArg, ...args) { + // eslint-disable-next-line no-unused-vars + using scope = this.withStoreScope(data); + return ReflectApply(fn, thisArg, args); } } @@ -228,6 +248,13 @@ class Channel { runStores(data, fn, thisArg, ...args) { return ReflectApply(fn, thisArg, args); } + + withStoreScope() { + // Return no-op disposable for inactive channels + return { + [SymbolDispose]() {}, + }; + } } const channels = new WeakRefMap(); @@ -258,12 +285,9 @@ function hasSubscribers(name) { return channel.hasSubscribers; } -const traceEvents = [ +const boundedEvents = [ 'start', 'end', - 'asyncStart', - 'asyncEnd', - 'error', ]; function assertChannel(value, name) { @@ -272,7 +296,12 @@ function assertChannel(value, name) { } } -function tracingChannelFrom(nameOrChannels, name) { +function emitNonThenableWarning(fn) { + process.emitWarning(`tracePromise was called with the function '${fn.name || ''}', ` + + 'which returned a non-thenable.'); +} + +function channelFromMap(nameOrChannels, name, className) { if (typeof nameOrChannels === 'string') { return channel(`tracing:${nameOrChannels}:${name}`); } @@ -284,37 +313,62 @@ function tracingChannelFrom(nameOrChannels, name) { } throw new ERR_INVALID_ARG_TYPE('nameOrChannels', - ['string', 'object', 'TracingChannel'], + ['string', 'object', className], nameOrChannels); } -function emitNonThenableWarning(fn) { - process.emitWarning(`tracePromise was called with the function '${fn.name || ''}', ` + - 'which returned a non-thenable.'); +class BoundedChannelScope { + #context; + #end; + #scope; + + constructor(boundedChannel, context) { + // Only proceed if there are subscribers + if (!boundedChannel.hasSubscribers) { + return; + } + + const { start, end } = boundedChannel; + this.#context = context; + this.#end = end; + + // Use RunStoresScope for the start channel + this.#scope = new RunStoresScope(start, context); + } + + [SymbolDispose]() { + if (!this.#scope) { + return; + } + + // Publish end event + this.#end.publish(this.#context); + + // Dispose the start scope to restore stores + this.#scope[SymbolDispose](); + this.#scope = undefined; + } } -class TracingChannel { +class BoundedChannel { constructor(nameOrChannels) { - for (let i = 0; i < traceEvents.length; ++i) { - const eventName = traceEvents[i]; + for (let i = 0; i < boundedEvents.length; ++i) { + const eventName = boundedEvents[i]; ObjectDefineProperty(this, eventName, { __proto__: null, - value: tracingChannelFrom(nameOrChannels, eventName), + value: channelFromMap(nameOrChannels, eventName, 'BoundedChannel'), }); } } get hasSubscribers() { return this.start?.hasSubscribers || - this.end?.hasSubscribers || - this.asyncStart?.hasSubscribers || - this.asyncEnd?.hasSubscribers || - this.error?.hasSubscribers; + this.end?.hasSubscribers; } subscribe(handlers) { - for (let i = 0; i < traceEvents.length; ++i) { - const name = traceEvents[i]; + for (let i = 0; i < boundedEvents.length; ++i) { + const name = boundedEvents[i]; if (!handlers[name]) continue; this[name]?.subscribe(handlers[name]); @@ -324,8 +378,8 @@ class TracingChannel { unsubscribe(handlers) { let done = true; - for (let i = 0; i < traceEvents.length; ++i) { - const name = traceEvents[i]; + for (let i = 0; i < boundedEvents.length; ++i) { + const name = boundedEvents[i]; if (!handlers[name]) continue; if (!this[name]?.unsubscribe(handlers[name])) { @@ -336,26 +390,148 @@ class TracingChannel { return done; } + withScope(context = {}) { + return new BoundedChannelScope(this, context); + } + + run(context, fn, thisArg, ...args) { + context ??= {}; + // eslint-disable-next-line no-unused-vars + using scope = this.withScope(context); + return ReflectApply(fn, thisArg, args); + } +} + +function boundedChannel(nameOrChannels) { + return new BoundedChannel(nameOrChannels); +} + +class TracingChannel { + #callWindow; + #continuationWindow; + + constructor(nameOrChannels) { + // Create a BoundedChannel for start/end (call window) + if (typeof nameOrChannels === 'string') { + this.#callWindow = new BoundedChannel(nameOrChannels); + this.#continuationWindow = new BoundedChannel({ + start: channel(`tracing:${nameOrChannels}:asyncStart`), + end: channel(`tracing:${nameOrChannels}:asyncEnd`), + }); + } else if (typeof nameOrChannels === 'object') { + this.#callWindow = new BoundedChannel({ + start: nameOrChannels.start, + end: nameOrChannels.end, + }); + this.#continuationWindow = new BoundedChannel({ + start: nameOrChannels.asyncStart, + end: nameOrChannels.asyncEnd, + }); + } + + // Create individual channel for error + ObjectDefineProperty(this, 'error', { + __proto__: null, + value: channelFromMap(nameOrChannels, 'error', 'TracingChannel'), + }); + } + + get start() { + return this.#callWindow.start; + } + + get end() { + return this.#callWindow.end; + } + + get asyncStart() { + return this.#continuationWindow.start; + } + + get asyncEnd() { + return this.#continuationWindow.end; + } + + get hasSubscribers() { + return this.#callWindow.hasSubscribers || + this.#continuationWindow.hasSubscribers || + this.error?.hasSubscribers; + } + + subscribe(handlers) { + // Subscribe to call window (start/end) + if (handlers.start || handlers.end) { + this.#callWindow.subscribe({ + start: handlers.start, + end: handlers.end, + }); + } + + // Subscribe to continuation window (asyncStart/asyncEnd) + if (handlers.asyncStart || handlers.asyncEnd) { + this.#continuationWindow.subscribe({ + start: handlers.asyncStart, + end: handlers.asyncEnd, + }); + } + + // Subscribe to error channel + if (handlers.error) { + this.error.subscribe(handlers.error); + } + } + + unsubscribe(handlers) { + let done = true; + + // Unsubscribe from call window + if (handlers.start || handlers.end) { + if (!this.#callWindow.unsubscribe({ + start: handlers.start, + end: handlers.end, + })) { + done = false; + } + } + + // Unsubscribe from continuation window + if (handlers.asyncStart || handlers.asyncEnd) { + if (!this.#continuationWindow.unsubscribe({ + start: handlers.asyncStart, + end: handlers.asyncEnd, + })) { + done = false; + } + } + + // Unsubscribe from error channel + if (handlers.error) { + if (!this.error.unsubscribe(handlers.error)) { + done = false; + } + } + + return done; + } + traceSync(fn, context = {}, thisArg, ...args) { if (!this.hasSubscribers) { return ReflectApply(fn, thisArg, args); } - const { start, end, error } = this; + const { error } = this; - return start.runStores(context, () => { - try { - const result = ReflectApply(fn, thisArg, args); - context.result = result; - return result; - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); - } - }); + // eslint-disable-next-line no-unused-vars + using scope = this.#callWindow.withScope(context); + try { + const result = ReflectApply(fn, thisArg, args); + context.result = result; + return result; + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } } tracePromise(fn, context = {}, thisArg, ...args) { @@ -367,44 +543,50 @@ class TracingChannel { return result; } - const { start, end, asyncStart, asyncEnd, error } = this; + const { error } = this; + const continuationWindow = this.#continuationWindow; function reject(err) { context.error = err; error.publish(context); - asyncStart.publish(context); + // Use continuation window for asyncStart/asyncEnd + // eslint-disable-next-line no-unused-vars + using scope = continuationWindow.withScope(context); // TODO: Is there a way to have asyncEnd _after_ the continuation? - asyncEnd.publish(context); - throw err; + return PromiseReject(err); } function resolve(result) { context.result = result; - asyncStart.publish(context); + // Use continuation window for asyncStart/asyncEnd + // eslint-disable-next-line no-unused-vars + using scope = continuationWindow.withScope(context); // TODO: Is there a way to have asyncEnd _after_ the continuation? - asyncEnd.publish(context); return result; } - return start.runStores(context, () => { - try { - const result = ReflectApply(fn, thisArg, args); - // If the return value is not a thenable, then return it with a warning. - // Do not publish to asyncStart/asyncEnd. - if (typeof result?.then !== 'function') { - emitNonThenableWarning(fn); - context.result = result; - return result; - } - return result.then(resolve, reject); - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); + // eslint-disable-next-line no-unused-vars + using scope = this.#callWindow.withScope(context); + try { + const result = ReflectApply(fn, thisArg, args); + // If the return value is not a thenable, return it directly with a warning. + // Do not publish to asyncStart/asyncEnd. + if (typeof result?.then !== 'function') { + emitNonThenableWarning(fn); + context.result = result; + return result; } - }); + // For native Promises use PromisePrototypeThen to avoid user overrides. + if (isPromise(result)) { + return PromisePrototypeThen(result, resolve, reject); + } + // For custom thenables, call .then() directly to preserve the thenable type. + return result.then(resolve, reject); + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } } traceCallback(fn, position = -1, context = {}, thisArg, ...args) { @@ -412,7 +594,8 @@ class TracingChannel { return ReflectApply(fn, thisArg, args); } - const { start, end, asyncStart, asyncEnd, error } = this; + const { error } = this; + const continuationWindow = this.#continuationWindow; function wrappedCallback(err, res) { if (err) { @@ -422,31 +605,25 @@ class TracingChannel { context.result = res; } - // Using runStores here enables manual context failure recovery - asyncStart.runStores(context, () => { - try { - return ReflectApply(callback, this, arguments); - } finally { - asyncEnd.publish(context); - } - }); + // Use continuation window for asyncStart/asyncEnd around callback + // eslint-disable-next-line no-unused-vars + using scope = continuationWindow.withScope(context); + return ReflectApply(callback, this, arguments); } const callback = ArrayPrototypeAt(args, position); validateFunction(callback, 'callback'); ArrayPrototypeSplice(args, position, 1, wrappedCallback); - return start.runStores(context, () => { - try { - return ReflectApply(fn, thisArg, args); - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); - } - }); + // eslint-disable-next-line no-unused-vars + using scope = this.#callWindow.withScope(context); + try { + return ReflectApply(fn, thisArg, args); + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } } } @@ -462,5 +639,7 @@ module.exports = { subscribe, tracingChannel, unsubscribe, + boundedChannel, Channel, + BoundedChannel, }; diff --git a/test/parallel/test-diagnostics-channel-bounded-channel-run-transform-error.js b/test/parallel/test-diagnostics-channel-bounded-channel-run-transform-error.js new file mode 100644 index 00000000000000..e86dcf0da207d8 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-bounded-channel-run-transform-error.js @@ -0,0 +1,66 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test BoundedChannel.run() with store transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const boundedChannel = dc.boundedChannel('test-run-transform-error'); +const store = new AsyncLocalStorage(); +const events = []; + +const transformError = new Error('transform failed'); + +// Set up uncaughtException handler to catch the transform error +process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err, transformError); + events.push('uncaughtException'); +})); + +boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, +}); + +// Bind store with a transform that throws +boundedChannel.start.bindStore(store, () => { + throw transformError; +}); + +// Store should remain undefined since transform will fail +assert.strictEqual(store.getStore(), undefined); + +const result = boundedChannel.run({ operationId: '123' }, common.mustCall(() => { + // Store should still be undefined because transform threw + assert.strictEqual(store.getStore(), undefined); + + events.push('inside-run'); + + return 42; +})); + +// Should still return the result despite transform error +assert.strictEqual(result, 42); + +// Store should still be undefined after run +assert.strictEqual(store.getStore(), undefined); + +// Start and end events should still be published despite transform error +assert.strictEqual(events.length, 3); +assert.strictEqual(events[0].type, 'start'); +assert.strictEqual(events[0].data.operationId, '123'); +assert.strictEqual(events[1], 'inside-run'); +assert.strictEqual(events[2].type, 'end'); +assert.strictEqual(events[2].data.operationId, '123'); + +// Validate uncaughtException was triggered via nextTick +process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(events.length, 4); + assert.strictEqual(events[3], 'uncaughtException'); +})); diff --git a/test/parallel/test-diagnostics-channel-bounded-channel-run.js b/test/parallel/test-diagnostics-channel-bounded-channel-run.js new file mode 100644 index 00000000000000..3fd5422f8cc090 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-bounded-channel-run.js @@ -0,0 +1,125 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test basic run functionality +{ + const boundedChannel = dc.boundedChannel('test-run-basic'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = { id: 123 }; + const result = boundedChannel.run(context, () => { + return 'success'; + }); + + assert.strictEqual(result, 'success'); + assert.strictEqual(events.length, 2); + assert.deepStrictEqual(events, [ + { type: 'start', data: { id: 123 } }, + { type: 'end', data: { id: 123 } }, + ]); +} + +// Test run with error +{ + const boundedChannel = dc.boundedChannel('test-run-error'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = { id: 456 }; + const testError = new Error('test error'); + + assert.throws(() => { + boundedChannel.run(context, () => { + throw testError; + }); + }, testError); + + // BoundedChannel does not handle errors - they just propagate + // Only start and end events are published + assert.strictEqual(events.length, 2); + assert.deepStrictEqual(events, [ + { type: 'start', data: { id: 456 } }, + { type: 'end', data: { id: 456 } }, + ]); +} + +// Test run with thisArg and args +{ + const boundedChannel = dc.boundedChannel('test-run-args'); + + const obj = { value: 10 }; + const result = boundedChannel.run({}, function(a, b) { + return this.value + a + b; + }, obj, 5, 15); + + assert.strictEqual(result, 30); +} + +// Test run with AsyncLocalStorage +{ + const boundedChannel = dc.boundedChannel('test-run-store'); + const store = new AsyncLocalStorage(); + const events = []; + + boundedChannel.start.bindStore(store, (context) => { + return { traceId: context.traceId }; + }); + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', store: store.getStore() }); + }, + end(message) { + events.push({ type: 'end', store: store.getStore() }); + }, + }); + + const result = boundedChannel.run({ traceId: 'abc123' }, () => { + events.push({ type: 'inside', store: store.getStore() }); + return 'result'; + }); + + assert.strictEqual(result, 'result'); + assert.strictEqual(events.length, 3); + + // Innert events should have store set + assert.deepStrictEqual(events, [ + { type: 'start', store: { traceId: 'abc123' } }, + { type: 'inside', store: { traceId: 'abc123' } }, + { type: 'end', store: { traceId: 'abc123' } }, + ]); + + // Store should be undefined outside + assert.strictEqual(store.getStore(), undefined); +} + +// Test run without subscribers +{ + const boundedChannel = dc.boundedChannel('test-run-no-subs'); + + const result = boundedChannel.run({}, () => { + return 'fast path'; + }); + + assert.strictEqual(result, 'fast path'); +} diff --git a/test/parallel/test-diagnostics-channel-bounded-channel-scope-error.js b/test/parallel/test-diagnostics-channel-bounded-channel-scope-error.js new file mode 100644 index 00000000000000..c5d00256ec9685 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-bounded-channel-scope-error.js @@ -0,0 +1,90 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test scope with thrown error +{ + const boundedChannel = dc.boundedChannel('test-scope-throw'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = { id: 1 }; + const testError = new Error('thrown error'); + + assert.throws(() => { + using scope = boundedChannel.withScope(context); + context.result = 'partial'; + throw testError; + }, testError); + + // End event should still be published + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[1].type, 'end'); + + // Context should have partial result but no error from throw + assert.strictEqual(context.result, 'partial'); + assert.strictEqual(context.error, undefined); +} + +// Test store restoration on error +{ + const boundedChannel = dc.boundedChannel('test-scope-store-error'); + const store = new AsyncLocalStorage(); + + boundedChannel.start.bindStore(store, (context) => context.value); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + store.enterWith('before'); + assert.strictEqual(store.getStore(), 'before'); + + const testError = new Error('test'); + + assert.throws(() => { + using scope = boundedChannel.withScope({ value: 'during' }); + assert.strictEqual(store.getStore(), 'during'); + throw testError; + }, testError); + + // Store should be restored even after error + assert.strictEqual(store.getStore(), 'before'); +} + +// Test dispose during exception handling +{ + const boundedChannel = dc.boundedChannel('test-scope-dispose-exception'); + const events = []; + + boundedChannel.subscribe({ + start() { + events.push('start'); + }, + end() { + events.push('end'); + }, + }); + + // Dispose should complete even when exception is thrown + assert.throws(() => { + using scope = boundedChannel.withScope({}); + throw new Error('original error'); + }, /original error/); + + // End event should have been called + assert.deepStrictEqual(events, ['start', 'end']); +} diff --git a/test/parallel/test-diagnostics-channel-bounded-channel-scope-nested.js b/test/parallel/test-diagnostics-channel-bounded-channel-scope-nested.js new file mode 100644 index 00000000000000..1e1c4f3e306fe3 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-bounded-channel-scope-nested.js @@ -0,0 +1,257 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test nested scopes +{ + const boundedChannel = dc.boundedChannel('test-nested-basic'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', id: message.id }); + }, + end(message) { + events.push({ type: 'end', id: message.id }); + }, + }); + + { + using outer = boundedChannel.withScope({ id: 'outer' }); + events.push({ type: 'work', id: 'outer' }); + + { + using inner = boundedChannel.withScope({ id: 'inner' }); + events.push({ type: 'work', id: 'inner' }); + } + + events.push({ type: 'work', id: 'outer-after' }); + } + + assert.strictEqual(events.length, 7); + assert.deepStrictEqual(events[0], { type: 'start', id: 'outer' }); + assert.deepStrictEqual(events[1], { type: 'work', id: 'outer' }); + assert.deepStrictEqual(events[2], { type: 'start', id: 'inner' }); + assert.deepStrictEqual(events[3], { type: 'work', id: 'inner' }); + assert.deepStrictEqual(events[4], { type: 'end', id: 'inner' }); + assert.deepStrictEqual(events[5], { type: 'work', id: 'outer-after' }); + assert.deepStrictEqual(events[6], { type: 'end', id: 'outer' }); +} + +// Test nested scopes with stores +{ + const boundedChannel = dc.boundedChannel('test-nested-stores'); + const store = new AsyncLocalStorage(); + const storeValues = []; + + boundedChannel.start.bindStore(store, (context) => context.id); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + assert.strictEqual(store.getStore(), undefined); + + { + using outer = boundedChannel.withScope({ id: 'outer' }); + storeValues.push(store.getStore()); + + { + using inner = boundedChannel.withScope({ id: 'inner' }); + storeValues.push(store.getStore()); + } + + // Should restore to outer + storeValues.push(store.getStore()); + } + + // Should restore to undefined + storeValues.push(store.getStore()); + + assert.deepStrictEqual(storeValues, ['outer', 'inner', 'outer', undefined]); +} + +// Test nested scopes with different channels +{ + const channel1 = dc.boundedChannel('test-nested-chan1'); + const channel2 = dc.boundedChannel('test-nested-chan2'); + const events = []; + + channel1.subscribe({ + start({ ...data }) { + events.push({ channel: 1, type: 'start', data }); + }, + end({ ...data }) { + events.push({ channel: 1, type: 'end', data }); + }, + }); + + channel2.subscribe({ + start({ ...data }) { + events.push({ channel: 2, type: 'start', data }); + }, + end({ ...data }) { + events.push({ channel: 2, type: 'end', data }); + }, + }); + + const contextA = { id: 'A' }; + const contextB = { id: 'B' }; + { + using scope1 = channel1.withScope(contextA); + + { + using scope2 = channel2.withScope(contextB); + contextB.result = 'B-result'; + } + + contextA.result = 'A-result'; + } + + assert.strictEqual(events.length, 4); + assert.deepStrictEqual(events, [ + { channel: 1, type: 'start', data: { id: 'A' } }, + { channel: 2, type: 'start', data: { id: 'B' } }, + { channel: 2, type: 'end', data: { id: 'B', result: 'B-result' } }, + { channel: 1, type: 'end', data: { id: 'A', result: 'A-result' } }, + ]); +} + +// Test nested scopes with shared store +{ + const channel1 = dc.boundedChannel('test-nested-shared1'); + const channel2 = dc.boundedChannel('test-nested-shared2'); + const store = new AsyncLocalStorage(); + const storeValues = []; + + channel1.start.bindStore(store, (context) => ({ from: 'channel1', ...context })); + channel2.start.bindStore(store, (context) => ({ from: 'channel2', ...context })); + + channel1.subscribe({ start() {}, end() {} }); + channel2.subscribe({ start() {}, end() {} }); + + { + using scope1 = channel1.withScope({ id: 1 }); + storeValues.push({ ...store.getStore() }); + + { + using scope2 = channel2.withScope({ id: 2 }); + storeValues.push({ ...store.getStore() }); + } + + // Should restore to channel1's store value + storeValues.push({ ...store.getStore() }); + } + + assert.strictEqual(storeValues.length, 3); + assert.deepStrictEqual(storeValues[0], { from: 'channel1', id: 1 }); + assert.deepStrictEqual(storeValues[1], { from: 'channel2', id: 2 }); + assert.deepStrictEqual(storeValues[2], { from: 'channel1', id: 1 }); +} + +// Test deeply nested scopes +{ + const boundedChannel = dc.boundedChannel('test-nested-deep'); + const store = new AsyncLocalStorage(); + const depths = []; + + boundedChannel.start.bindStore(store, (context) => context.depth); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + { + using s1 = boundedChannel.withScope({ depth: 1 }); + depths.push(store.getStore()); + + { + using s2 = boundedChannel.withScope({ depth: 2 }); + depths.push(store.getStore()); + + { + using s3 = boundedChannel.withScope({ depth: 3 }); + depths.push(store.getStore()); + + { + using s4 = boundedChannel.withScope({ depth: 4 }); + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + + assert.deepStrictEqual(depths, [1, 2, 3, 4, 3, 2, 1, undefined]); +} + +// Test nested scopes with errors +{ + const boundedChannel = dc.boundedChannel('test-nested-error'); + const store = new AsyncLocalStorage(); + const events = []; + + boundedChannel.start.bindStore(store, (context) => context.id); + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', id: message.id }); + }, + end(message) { + events.push({ type: 'end', id: message.id }); + }, + }); + + const testError = new Error('inner error'); + + assert.throws(() => { + using outer = boundedChannel.withScope({ id: 'outer' }); + events.push({ type: 'store', value: store.getStore() }); + + assert.throws(() => { + using inner = boundedChannel.withScope({ id: 'inner' }); + events.push({ type: 'store', value: store.getStore() }); + throw testError; + }, testError); + + // After inner error, should be back to outer store + events.push({ type: 'store', value: store.getStore() }); + + throw new Error('outer error'); + }, /outer error/); + + // Both start and end events should have been published for both scopes + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[0].id, 'outer'); + assert.strictEqual(events[1].type, 'store'); + assert.strictEqual(events[1].value, 'outer'); + + assert.strictEqual(events[2].type, 'start'); + assert.strictEqual(events[2].id, 'inner'); + assert.strictEqual(events[3].type, 'store'); + assert.strictEqual(events[3].value, 'inner'); + + assert.strictEqual(events[4].type, 'end'); + assert.strictEqual(events[4].id, 'inner'); + + assert.strictEqual(events[5].type, 'store'); + assert.strictEqual(events[5].value, 'outer'); + + assert.strictEqual(events[6].type, 'end'); + assert.strictEqual(events[6].id, 'outer'); + + // Store should be restored + assert.strictEqual(store.getStore(), undefined); +} diff --git a/test/parallel/test-diagnostics-channel-bounded-channel-scope-transform-error.js b/test/parallel/test-diagnostics-channel-bounded-channel-scope-transform-error.js new file mode 100644 index 00000000000000..ca3d8c46d34e0c --- /dev/null +++ b/test/parallel/test-diagnostics-channel-bounded-channel-scope-transform-error.js @@ -0,0 +1,66 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test BoundedChannelScope with transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const boundedChannel = dc.boundedChannel('test-transform-error'); +const store = new AsyncLocalStorage(); +const events = []; + +const transformError = new Error('transform failed'); + +// Set up uncaughtException handler to catch the transform error +process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err, transformError); + events.push('uncaughtException'); +})); + +boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, +}); + +// Bind store with a transform that throws +boundedChannel.start.bindStore(store, () => { + throw transformError; +}); + +// Store should remain undefined since transform will fail +assert.strictEqual(store.getStore(), undefined); + +const context = { id: 123 }; +{ + // eslint-disable-next-line no-unused-vars + using scope = boundedChannel.withScope(context); + + // Store should still be undefined because transform threw + assert.strictEqual(store.getStore(), undefined); + + events.push('inside-scope'); + context.result = 42; +} + +// Store should still be undefined after scope exit +assert.strictEqual(store.getStore(), undefined); + +// Start and end events should still be published despite transform error +assert.strictEqual(events.length, 3); +assert.strictEqual(events[0].type, 'start'); +assert.strictEqual(events[0].data.id, 123); +assert.strictEqual(events[1], 'inside-scope'); +assert.strictEqual(events[2].type, 'end'); +assert.strictEqual(events[2].data.result, 42); + +// Validate uncaughtException was triggered via nextTick +process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(events.length, 4); + assert.strictEqual(events[3], 'uncaughtException'); +})); diff --git a/test/parallel/test-diagnostics-channel-bounded-channel-scope.js b/test/parallel/test-diagnostics-channel-bounded-channel-scope.js new file mode 100644 index 00000000000000..9a6d8da4155e38 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-bounded-channel-scope.js @@ -0,0 +1,206 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test basic scope with using +{ + const boundedChannel = dc.boundedChannel('test-scope-basic'); + const events = []; + + boundedChannel.subscribe({ + start({ ...data }) { + events.push({ type: 'start', data }); + }, + end({ ...data }) { + events.push({ type: 'end', data }); + }, + }); + + const context = { id: 123 }; + + { + using scope = boundedChannel.withScope(context); + assert.ok(scope); + context.value = 'inside'; + } + + assert.strictEqual(events.length, 2); + assert.deepStrictEqual(events, [ + { + type: 'start', + data: { id: 123 } + }, + { + type: 'end', + data: { + id: 123, + value: 'inside' + } + }, + ]); +} + +// Test scope with result setter +{ + const boundedChannel = dc.boundedChannel('test-scope-result'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = {}; + + { + using scope = boundedChannel.withScope(context); + context.result = 42; + } + + assert.strictEqual(context.result, 42); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[1].data.result, 42); +} + +// Test scope with error setter +{ + const boundedChannel = dc.boundedChannel('test-scope-error-setter'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = {}; + + { + using scope = boundedChannel.withScope(context); + context.result = 'test result'; + } + + // BoundedChannel does not handle errors - only start and end + assert.strictEqual(context.result, 'test result'); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[1].type, 'end'); +} + +// Test scope with AsyncLocalStorage +{ + const boundedChannel = dc.boundedChannel('test-scope-store'); + const store = new AsyncLocalStorage(); + const events = []; + + boundedChannel.start.bindStore(store, (context) => { + return { traceId: context.traceId }; + }); + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', store: store.getStore() }); + }, + end(message) { + events.push({ type: 'end', store: store.getStore() }); + }, + }); + + assert.strictEqual(store.getStore(), undefined); + + { + using scope = boundedChannel.withScope({ traceId: 'xyz789' }); + + // Store should be set inside scope + assert.deepStrictEqual(store.getStore(), { traceId: 'xyz789' }); + + events.push({ type: 'inside', store: store.getStore() }); + } + + // Store should be restored after scope + assert.strictEqual(store.getStore(), undefined); + + assert.strictEqual(events.length, 3); + assert.strictEqual(events[0].type, 'start'); + assert.deepStrictEqual(events[0].store, { traceId: 'xyz789' }); + assert.strictEqual(events[1].type, 'inside'); + assert.deepStrictEqual(events[1].store, { traceId: 'xyz789' }); + assert.strictEqual(events[2].type, 'end'); + assert.deepStrictEqual(events[2].store, { traceId: 'xyz789' }); +} + +// Test scope without subscribers (no-op) +{ + const boundedChannel = dc.boundedChannel('test-scope-no-subs'); + + const context = {}; + + { + using scope = boundedChannel.withScope(context); + context.result = 'value'; + } + + // Context should still be updated even without subscribers + assert.strictEqual(context.result, 'value'); +} + +// Test manual dispose via Symbol.dispose +{ + const boundedChannel = dc.boundedChannel('test-scope-manual'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push('start'); + }, + end(message) { + events.push('end'); + }, + }); + + const scope = boundedChannel.withScope({}); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0], 'start'); + + scope[Symbol.dispose](); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[1], 'end'); + + // Double dispose should be idempotent + scope[Symbol.dispose](); + assert.strictEqual(events.length, 2); +} + +// Test scope with store restore to previous value +{ + const boundedChannel = dc.boundedChannel('test-scope-restore'); + const store = new AsyncLocalStorage(); + + boundedChannel.start.bindStore(store, (context) => context.value); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + store.enterWith('initial'); + assert.strictEqual(store.getStore(), 'initial'); + + { + using scope = boundedChannel.withScope({ value: 'scoped' }); + assert.strictEqual(store.getStore(), 'scoped'); + } + + // Should restore to previous value + assert.strictEqual(store.getStore(), 'initial'); +} diff --git a/test/parallel/test-diagnostics-channel-bounded-channel.js b/test/parallel/test-diagnostics-channel-bounded-channel.js new file mode 100644 index 00000000000000..90db374a4bf71c --- /dev/null +++ b/test/parallel/test-diagnostics-channel-bounded-channel.js @@ -0,0 +1,105 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); + +// Test BoundedChannel exports +{ + assert.strictEqual(typeof dc.boundedChannel, 'function'); + assert.strictEqual(typeof dc.BoundedChannel, 'function'); + + const wc = dc.boundedChannel('test-export'); + assert.ok(wc instanceof dc.BoundedChannel); +} + +// Test basic BoundedChannel creation and properties +{ + const boundedChannel = dc.boundedChannel('test-window-basic'); + + assert.ok(boundedChannel.start); + assert.ok(boundedChannel.end); + + assert.strictEqual(boundedChannel.start.name, 'tracing:test-window-basic:start'); + assert.strictEqual(boundedChannel.end.name, 'tracing:test-window-basic:end'); + + assert.strictEqual(boundedChannel.hasSubscribers, false); + + assert.strictEqual(typeof boundedChannel.subscribe, 'function'); + assert.strictEqual(typeof boundedChannel.unsubscribe, 'function'); + assert.strictEqual(typeof boundedChannel.run, 'function'); + assert.strictEqual(typeof boundedChannel.withScope, 'function'); +} + +// Test BoundedChannel with channel objects +{ + const startChannel = dc.channel('custom:start'); + const endChannel = dc.channel('custom:end'); + + const boundedChannel = dc.boundedChannel({ + start: startChannel, + end: endChannel, + }); + + assert.strictEqual(boundedChannel.start, startChannel); + assert.strictEqual(boundedChannel.end, endChannel); +} + +// Test subscribe/unsubscribe +{ + const boundedChannel = dc.boundedChannel('test-window-subscribe'); + const events = []; + + const handlers = { + start(message) { + events.push({ type: 'start', message }); + }, + end(message) { + events.push({ type: 'end', message }); + }, + }; + + assert.strictEqual(boundedChannel.hasSubscribers, false); + + boundedChannel.subscribe(handlers); + + assert.strictEqual(boundedChannel.hasSubscribers, true); + + // Test that events are received + boundedChannel.start.publish({ test: 'start' }); + boundedChannel.end.publish({ test: 'end' }); + + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[0].message.test, 'start'); + assert.strictEqual(events[1].type, 'end'); + assert.strictEqual(events[1].message.test, 'end'); + + // Test unsubscribe + const result = boundedChannel.unsubscribe(handlers); + assert.strictEqual(result, true); + assert.strictEqual(boundedChannel.hasSubscribers, false); + + // Test unsubscribe when not subscribed + const result2 = boundedChannel.unsubscribe(handlers); + assert.strictEqual(result2, false); +} + +// Test partial subscription +{ + const boundedChannel = dc.boundedChannel('test-window-partial'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push('start'); + }, + }); + + assert.strictEqual(boundedChannel.hasSubscribers, true); + + boundedChannel.start.publish({}); + boundedChannel.end.publish({}); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0], 'start'); +} diff --git a/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js b/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js new file mode 100644 index 00000000000000..04bc7eaee46b98 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js @@ -0,0 +1,57 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test RunStoresScope with transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const channel = dc.channel('test-transform-error'); +const store = new AsyncLocalStorage(); +const events = []; + +const transformError = new Error('transform failed'); + +// Set up uncaughtException handler to catch the transform error +process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err, transformError); + events.push('uncaughtException'); +})); + +channel.subscribe((message) => { + events.push({ type: 'message', data: message }); +}); + +// Bind store with a transform that throws +channel.bindStore(store, () => { + throw transformError; +}); + +// Store should remain undefined since transform failed +assert.strictEqual(store.getStore(), undefined); + +{ + // eslint-disable-next-line no-unused-vars + using scope = channel.withStoreScope({ value: 'test' }); + + // Store should still be undefined because transform threw + assert.strictEqual(store.getStore(), undefined); + + events.push('inside-scope'); +} + +// Store should still be undefined after scope exit +assert.strictEqual(store.getStore(), undefined); + +// Message should still be published despite transform error +assert.strictEqual(events.length, 2); +assert.strictEqual(events[0].type, 'message'); +assert.strictEqual(events[0].data.value, 'test'); +assert.strictEqual(events[1], 'inside-scope'); + +// Validate uncaughtException was triggered via nextTick +process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(events.length, 3); + assert.strictEqual(events[2], 'uncaughtException'); +})); diff --git a/test/parallel/test-diagnostics-channel-run-stores-scope.js b/test/parallel/test-diagnostics-channel-run-stores-scope.js new file mode 100644 index 00000000000000..54b4417882d9d3 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-run-stores-scope.js @@ -0,0 +1,206 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test basic RunStoresScope with active channel +{ + const channel = dc.channel('test-run-stores-scope-basic'); + const store = new AsyncLocalStorage(); + const events = []; + + channel.subscribe((message) => { + events.push({ type: 'message', data: message, store: store.getStore() }); + }); + + channel.bindStore(store, (data) => { + return { transformed: data.value }; + }); + + assert.strictEqual(store.getStore(), undefined); + + { + using scope = channel.withStoreScope({ value: 'test' }); + + // Store should be set + assert.deepStrictEqual(store.getStore(), { transformed: 'test' }); + + events.push({ type: 'inside', store: store.getStore() }); + } + + // Store should be restored + assert.strictEqual(store.getStore(), undefined); + + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'message'); + assert.strictEqual(events[0].data.value, 'test'); + assert.deepStrictEqual(events[0].store, { transformed: 'test' }); + + assert.strictEqual(events[1].type, 'inside'); + assert.deepStrictEqual(events[1].store, { transformed: 'test' }); +} + +// Test RunStoresScope with inactive channel (no-op) +{ + const channel = dc.channel('test-run-stores-scope-inactive'); + + // No subscribers, channel is inactive + { + using scope = channel.withStoreScope({ value: 'test' }); + assert.ok(scope); + } + + // Should not throw +} + +// Test RunStoresScope restores previous store value +{ + const channel = dc.channel('test-run-stores-scope-restore'); + const store = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + store.enterWith('initial'); + assert.strictEqual(store.getStore(), 'initial'); + + { + using scope = channel.withStoreScope('scoped'); + assert.strictEqual(store.getStore(), 'scoped'); + } + + // Should restore to previous value + assert.strictEqual(store.getStore(), 'initial'); +} + +// Test RunStoresScope with multiple stores +{ + const channel = dc.channel('test-run-stores-scope-multi'); + const store1 = new AsyncLocalStorage(); + const store2 = new AsyncLocalStorage(); + const store3 = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store1, (data) => `${data}-1`); + channel.bindStore(store2, (data) => `${data}-2`); + channel.bindStore(store3, (data) => `${data}-3`); + + { + using scope = channel.withStoreScope('test'); + + assert.strictEqual(store1.getStore(), 'test-1'); + assert.strictEqual(store2.getStore(), 'test-2'); + assert.strictEqual(store3.getStore(), 'test-3'); + } + + assert.strictEqual(store1.getStore(), undefined); + assert.strictEqual(store2.getStore(), undefined); + assert.strictEqual(store3.getStore(), undefined); +} + +// Test manual dispose via Symbol.dispose +{ + const channel = dc.channel('test-run-stores-scope-manual'); + const store = new AsyncLocalStorage(); + const events = []; + + channel.subscribe((message) => { + events.push(message); + }); + + channel.bindStore(store, (data) => data); + + const scope = channel.withStoreScope('test'); + + assert.strictEqual(events.length, 1); + assert.strictEqual(store.getStore(), 'test'); + + scope[Symbol.dispose](); + + // Store should be restored + assert.strictEqual(store.getStore(), undefined); + + // Double dispose should be idempotent + scope[Symbol.dispose](); + assert.strictEqual(store.getStore(), undefined); +} + +// Test nested RunStoresScope +{ + const channel = dc.channel('test-run-stores-scope-nested'); + const store = new AsyncLocalStorage(); + const storeValues = []; + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + { + using outer = channel.withStoreScope('outer'); + storeValues.push(store.getStore()); + + { + using inner = channel.withStoreScope('inner'); + storeValues.push(store.getStore()); + } + + // Should restore to outer + storeValues.push(store.getStore()); + } + + // Should restore to undefined + storeValues.push(store.getStore()); + + assert.deepStrictEqual(storeValues, ['outer', 'inner', 'outer', undefined]); +} + +// Test RunStoresScope with error during usage +{ + const channel = dc.channel('test-run-stores-scope-error'); + const store = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + store.enterWith('before'); + + const testError = new Error('test'); + + assert.throws(() => { + using scope = channel.withStoreScope('during'); + assert.strictEqual(store.getStore(), 'during'); + throw testError; + }, testError); + + // Store should be restored even after error + assert.strictEqual(store.getStore(), 'before'); +} + +// Test RunStoresScope with inactive channel (no stores or subscribers) +{ + const channel = dc.channel('test-run-stores-scope-inactive'); + + // Channel is inactive (no subscribers or bound stores) + { + using scope = channel.withStoreScope('test'); + // No-op disposable, nothing happens + assert.ok(scope); + } +} + +// Test RunStoresScope with Symbol.dispose +{ + const channel = dc.channel('test-run-stores-scope-symbol'); + const store = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + const scope = channel.withStoreScope('test'); + assert.strictEqual(store.getStore(), 'test'); + + // Call Symbol.dispose directly + scope[Symbol.dispose](); + assert.strictEqual(store.getStore(), undefined); +} diff --git a/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js b/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js index 5292a6fe096bae..3fdacf2f275b4a 100644 --- a/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js +++ b/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js @@ -16,7 +16,7 @@ channel.start.bindStore(store, common.mustCall(() => { return firstContext; })); -channel.asyncStart.bindStore(store, common.mustNotCall(() => { +channel.asyncStart.bindStore(store, common.mustCall(() => { return secondContext; })); @@ -27,5 +27,7 @@ channel.tracePromise(common.mustCall(async () => { // Should _not_ switch to second context as promises don't have an "after" // point at which to do a runStores. assert.deepStrictEqual(store.getStore(), firstContext); +})).then(common.mustCall(() => { + assert.strictEqual(store.getStore(), undefined); })); assert.strictEqual(store.getStore(), undefined); From 1ccae7cce446aa564339940cec7828d05e2fc2fa Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 31 Mar 2026 21:57:34 +0200 Subject: [PATCH 6/7] crypto: unify asymmetric key import through KeyObjectHandle::Init Consolidate all asymmetric key import paths (DER/PEM, JWK, raw) into a single KeyObjectHandle::Init() entry point with a uniform signature. Remove the per-type C++ init methods (InitECRaw, InitEDRaw, InitPqcRaw, InitJwk, InitECPrivateRaw) and their JS-side callers, replacing them with shared C++ and JS helpers. createPublicKey, createPrivateKey, sign, verify, and other operations that accept key material now handle JWK and raw formats directly in C++, removing redundant JS-to-C++ key handle round-trips. Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/62499 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell --- lib/internal/crypto/aes.js | 31 +- lib/internal/crypto/cfrg.js | 157 +---- lib/internal/crypto/chacha20_poly1305.js | 31 +- lib/internal/crypto/cipher.js | 6 +- lib/internal/crypto/ec.js | 97 +-- lib/internal/crypto/kem.js | 6 +- lib/internal/crypto/keys.js | 265 +------- lib/internal/crypto/mac.js | 31 +- lib/internal/crypto/ml_dsa.js | 122 +--- lib/internal/crypto/ml_kem.js | 55 +- lib/internal/crypto/rsa.js | 82 +-- lib/internal/crypto/sig.js | 16 +- lib/internal/crypto/webcrypto_util.js | 93 +++ src/crypto/crypto_ec.cc | 100 ++- src/crypto/crypto_ec.h | 7 +- src/crypto/crypto_keys.cc | 636 ++++++++++-------- src/crypto/crypto_keys.h | 5 - src/crypto/crypto_ml_dsa.cc | 80 +++ src/crypto/crypto_ml_dsa.h | 2 + src/crypto/crypto_rsa.cc | 18 +- src/crypto/crypto_rsa.h | 5 +- src/crypto/crypto_sig.cc | 24 +- test/parallel/test-crypto-key-objects.js | 103 +++ .../test-crypto-pqc-key-objects-ml-dsa.js | 20 +- .../test-webcrypto-export-import-ml-dsa.js | 2 +- 25 files changed, 929 insertions(+), 1065 deletions(-) create mode 100644 lib/internal/crypto/webcrypto_util.js diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index c0765f75642189..cc443b575da46a 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -10,6 +10,8 @@ const { AESCipherJob, KeyObjectHandle, kCryptoJobAsync, + kKeyFormatJWK, + kKeyTypeSecret, kKeyVariantAES_CTR_128, kKeyVariantAES_CBC_128, kKeyVariantAES_GCM_128, @@ -30,7 +32,6 @@ const { const { hasAnyNotIn, jobPromise, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -47,6 +48,10 @@ const { kAlgorithm, } = require('internal/crypto/keys'); +const { + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const { generateKey: _generateKey, } = require('internal/crypto/keygen'); @@ -245,31 +250,11 @@ function aesImportKey( break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - - if (keyData.kty !== 'oct') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); - - if (usagesSet.size > 0 && - keyData.use !== undefined && - keyData.use !== 'enc') { - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } + validateJwk(keyData, 'oct', extractable, usagesSet, 'enc'); const handle = new KeyObjectHandle(); try { - handle.initJwk(keyData); + handle.init(kKeyTypeSecret, keyData, kKeyFormatJWK, null, null); } catch (err) { throw lazyDOMException( 'Invalid keyData', { name: 'DataError', cause: err }); diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index b943ba020750f9..58e8fb02943b78 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -6,15 +6,11 @@ const { TypedArrayPrototypeGetBuffer, } = primordials; -const { Buffer } = require('buffer'); - const { - KeyObjectHandle, SignJob, kCryptoJobAsync, kKeyFormatDER, - kKeyTypePrivate, - kKeyTypePublic, + kKeyFormatRawPublic, kSignJobModeSign, kSignJobModeVerify, kWebCryptoKeyFormatPKCS8, @@ -22,17 +18,10 @@ const { kWebCryptoKeyFormatSPKI, } = internalBinding('crypto'); -const { - codes: { - ERR_CRYPTO_INVALID_JWK, - }, -} = require('internal/errors'); - const { getUsagesUnion, hasAnyNotIn, jobPromise, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -48,13 +37,16 @@ const { const { InternalCryptoKey, - PrivateKeyObject, - PublicKeyObject, - createPrivateKey, - createPublicKey, kKeyType, } = require('internal/crypto/keys'); +const { + importDerKey, + importJwkKey, + importRawKey, + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const generateKeyPair = promisify(_generateKeyPair); function verifyAcceptableCfrgKeyUse(name, isPublic, usages) { @@ -81,39 +73,6 @@ function verifyAcceptableCfrgKeyUse(name, isPublic, usages) { } } -function createCFRGRawKey(name, keyData, isPublic) { - const handle = new KeyObjectHandle(); - - switch (name) { - case 'Ed25519': - case 'X25519': - if (keyData.byteLength !== 32) { - throw lazyDOMException( - `${name} raw keys must be exactly 32-bytes`, 'DataError'); - } - break; - case 'Ed448': - if (keyData.byteLength !== 57) { - throw lazyDOMException( - `${name} raw keys must be exactly 57-bytes`, 'DataError'); - } - break; - case 'X448': - if (keyData.byteLength !== 56) { - throw lazyDOMException( - `${name} raw keys must be exactly 56-bytes`, 'DataError'); - } - break; - } - - const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initEDRaw(name, keyData, keyType)) { - throw lazyDOMException('Invalid keyData', 'DataError'); - } - - return isPublic ? new PublicKeyObject(handle) : new PrivateKeyObject(handle); -} - async function cfrgGenerateKey(algorithm, extractable, keyUsages) { const { name } = algorithm; @@ -243,113 +202,36 @@ function cfrgImportKey( } case 'spki': { verifyAcceptableCfrgKeyUse(name, true, usagesSet); - try { - keyObject = createPublicKey({ - key: keyData, - format: 'der', - type: 'spki', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, true); break; } case 'pkcs8': { verifyAcceptableCfrgKeyUse(name, false, usagesSet); - try { - keyObject = createPrivateKey({ - key: keyData, - format: 'der', - type: 'pkcs8', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, false); break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - if (keyData.kty !== 'OKP') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); + const expectedUse = (name === 'X25519' || name === 'X448') ? 'enc' : 'sig'; + validateJwk(keyData, 'OKP', extractable, usagesSet, expectedUse); + if (keyData.crv !== name) throw lazyDOMException( 'JWK "crv" Parameter and algorithm name mismatch', 'DataError'); - const isPublic = keyData.d === undefined; - - if (usagesSet.size > 0 && keyData.use !== undefined) { - let checkUse; - switch (name) { - case 'Ed25519': - // Fall through - case 'Ed448': - checkUse = 'sig'; - break; - case 'X25519': - // Fall through - case 'X448': - checkUse = 'enc'; - break; - } - if (keyData.use !== checkUse) - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } if (keyData.alg !== undefined && (name === 'Ed25519' || name === 'Ed448')) { - if (keyData.alg !== name && keyData.alg !== 'EdDSA') { + if (keyData.alg !== name && keyData.alg !== 'EdDSA') throw lazyDOMException( - 'JWK "alg" does not match the requested algorithm', - 'DataError'); - } + 'JWK "alg" does not match the requested algorithm', 'DataError'); } - if (!isPublic && typeof keyData.x !== 'string') { - throw lazyDOMException('Invalid JWK', 'DataError'); - } - - verifyAcceptableCfrgKeyUse( - name, - isPublic, - usagesSet); - - try { - const publicKeyObject = createCFRGRawKey( - name, - Buffer.from(keyData.x, 'base64'), - true); - - if (isPublic) { - keyObject = publicKeyObject; - } else { - keyObject = createCFRGRawKey( - name, - Buffer.from(keyData.d, 'base64'), - false); - - if (!createPublicKey(keyObject).equals(publicKeyObject)) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - } - } catch (err) { - throw lazyDOMException('Invalid keyData', { name: 'DataError', cause: err }); - } + const isPublic = keyData.d === undefined; + verifyAcceptableCfrgKeyUse(name, isPublic, usagesSet); + keyObject = importJwkKey(isPublic, keyData); break; } case 'raw': { verifyAcceptableCfrgKeyUse(name, true, usagesSet); - keyObject = createCFRGRawKey(name, keyData, true); + keyObject = importRawKey(true, keyData, kKeyFormatRawPublic, name); break; } default: @@ -381,6 +263,7 @@ async function eddsaSignVerify(key, data, algorithm, signature) { undefined, undefined, undefined, + undefined, data, undefined, undefined, diff --git a/lib/internal/crypto/chacha20_poly1305.js b/lib/internal/crypto/chacha20_poly1305.js index a2b7c1fb04fb89..2230097c4c5c9f 100644 --- a/lib/internal/crypto/chacha20_poly1305.js +++ b/lib/internal/crypto/chacha20_poly1305.js @@ -9,12 +9,13 @@ const { ChaCha20Poly1305CipherJob, KeyObjectHandle, kCryptoJobAsync, + kKeyFormatJWK, + kKeyTypeSecret, } = internalBinding('crypto'); const { hasAnyNotIn, jobPromise, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -30,6 +31,10 @@ const { createSecretKey, } = require('internal/crypto/keys'); +const { + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const { randomBytes: _randomBytes, } = require('internal/crypto/random'); @@ -107,31 +112,11 @@ function c20pImportKey( break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - - if (keyData.kty !== 'oct') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); - - if (usagesSet.size > 0 && - keyData.use !== undefined && - keyData.use !== 'enc') { - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } + validateJwk(keyData, 'oct', extractable, usagesSet, 'enc'); const handle = new KeyObjectHandle(); try { - handle.initJwk(keyData); + handle.init(kKeyTypeSecret, keyData, kKeyFormatJWK, null, null); } catch (err) { throw lazyDOMException( 'Invalid keyData', { name: 'DataError', cause: err }); diff --git a/lib/internal/crypto/cipher.js b/lib/internal/crypto/cipher.js index 113713bc601014..c463ea41f763b3 100644 --- a/lib/internal/crypto/cipher.js +++ b/lib/internal/crypto/cipher.js @@ -64,7 +64,7 @@ const { StringDecoder } = require('string_decoder'); function rsaFunctionFor(method, defaultPadding, keyType) { return (options, buffer) => { - const { format, type, data, passphrase } = + const { format, type, data, passphrase, namedCurve } = keyType === 'private' ? preparePrivateKey(options) : preparePublicOrPrivateKey(options); @@ -76,8 +76,8 @@ function rsaFunctionFor(method, defaultPadding, keyType) { if (oaepLabel !== undefined) oaepLabel = getArrayBufferOrView(oaepLabel, 'key.oaepLabel', encoding); buffer = getArrayBufferOrView(buffer, 'buffer', encoding); - return method(data, format, type, passphrase, buffer, padding, oaepHash, - oaepLabel); + return method(data, format, type, passphrase, namedCurve, buffer, + padding, oaepHash, oaepLabel); }; } diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index a73b1c6c0bc4bf..3b0b35e8f0f822 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -11,7 +11,8 @@ const { SignJob, kCryptoJobAsync, kKeyFormatDER, - kKeyTypePrivate, + kKeyFormatRawPublic, + kKeyTypePublic, kSignJobModeSign, kSignJobModeVerify, kSigEncP1363, @@ -31,7 +32,6 @@ const { hasAnyNotIn, jobPromise, normalizeHashName, - validateKeyOps, kHandle, kKeyObject, kNamedCurveAliases, @@ -48,14 +48,17 @@ const { const { InternalCryptoKey, - PrivateKeyObject, - PublicKeyObject, - createPrivateKey, - createPublicKey, kAlgorithm, kKeyType, } = require('internal/crypto/keys'); +const { + importDerKey, + importJwkKey, + importRawKey, + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const generateKeyPair = promisify(_generateKeyPair); function verifyAcceptableEcKeyUse(name, isPublic, usages) { @@ -78,16 +81,6 @@ function verifyAcceptableEcKeyUse(name, isPublic, usages) { } } -function createECPublicKeyRaw(namedCurve, keyData) { - const handle = new KeyObjectHandle(); - - if (!handle.initECRaw(kNamedCurveAliases[namedCurve], keyData)) { - throw lazyDOMException('Invalid keyData', 'DataError'); - } - - return new PublicKeyObject(handle); -} - async function ecGenerateKey(algorithm, extractable, keyUsages) { const { name, namedCurve } = algorithm; @@ -173,7 +166,8 @@ function ecExportKey(key, format) { }[key[kAlgorithm].namedCurve]) { const raw = handle.exportECPublicRaw(POINT_CONVERSION_UNCOMPRESSED); const tmp = new KeyObjectHandle(); - tmp.initECRaw(kNamedCurveAliases[key[kAlgorithm].namedCurve], raw); + tmp.init(kKeyTypePublic, raw, kKeyFormatRawPublic, + 'ec', null, key[kAlgorithm].namedCurve); spki = tmp.export(kKeyFormatDER, kWebCryptoKeyFormatSPKI); } return TypedArrayPrototypeGetBuffer(spki); @@ -211,63 +205,23 @@ function ecImportKey( } case 'spki': { verifyAcceptableEcKeyUse(name, true, usagesSet); - try { - keyObject = createPublicKey({ - key: keyData, - format: 'der', - type: 'spki', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, true); break; } case 'pkcs8': { verifyAcceptableEcKeyUse(name, false, usagesSet); - try { - keyObject = createPrivateKey({ - key: keyData, - format: 'der', - type: 'pkcs8', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, false); break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - if (keyData.kty !== 'EC') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); + const expectedUse = name === 'ECDH' ? 'enc' : 'sig'; + validateJwk(keyData, 'EC', extractable, usagesSet, expectedUse); + if (keyData.crv !== namedCurve) throw lazyDOMException( 'JWK "crv" does not match the requested algorithm', 'DataError'); - verifyAcceptableEcKeyUse( - name, - keyData.d === undefined, - usagesSet); - - if (usagesSet.size > 0 && keyData.use !== undefined) { - const checkUse = name === 'ECDH' ? 'enc' : 'sig'; - if (keyData.use !== checkUse) - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } - if (algorithm.name === 'ECDSA' && keyData.alg !== undefined) { let algNamedCurve; switch (keyData.alg) { @@ -281,24 +235,14 @@ function ecImportKey( 'DataError'); } - const handle = new KeyObjectHandle(); - let type; - try { - type = handle.initJwk(keyData, namedCurve); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } - if (type === undefined) - throw lazyDOMException('Invalid keyData', 'DataError'); - keyObject = type === kKeyTypePrivate ? - new PrivateKeyObject(handle) : - new PublicKeyObject(handle); + const isPublic = keyData.d === undefined; + verifyAcceptableEcKeyUse(name, isPublic, usagesSet); + keyObject = importJwkKey(isPublic, keyData); break; } case 'raw': { verifyAcceptableEcKeyUse(name, true, usagesSet); - keyObject = createECPublicKeyRaw(namedCurve, keyData); + keyObject = importRawKey(true, keyData, kKeyFormatRawPublic, 'ec', namedCurve); break; } default: @@ -347,6 +291,7 @@ async function ecdsaSignVerify(key, data, { name, hash }, signature) { undefined, undefined, undefined, + undefined, data, hashname, undefined, // Salt length, not used with ECDSA diff --git a/lib/internal/crypto/kem.js b/lib/internal/crypto/kem.js index 43c7bde52ea99f..d38f8b1c59a050 100644 --- a/lib/internal/crypto/kem.js +++ b/lib/internal/crypto/kem.js @@ -42,6 +42,7 @@ function encapsulate(key, callback) { format: keyFormat, type: keyType, passphrase: keyPassphrase, + namedCurve: keyNamedCurve, } = preparePublicOrPrivateKey(key); const job = new KEMEncapsulateJob( @@ -49,7 +50,8 @@ function encapsulate(key, callback) { keyData, keyFormat, keyType, - keyPassphrase); + keyPassphrase, + keyNamedCurve); if (!callback) { const { 0: err, 1: result } = job.run(); @@ -79,6 +81,7 @@ function decapsulate(key, ciphertext, callback) { format: keyFormat, type: keyType, passphrase: keyPassphrase, + namedCurve: keyNamedCurve, } = preparePrivateKey(key); ciphertext = getArrayBufferOrView(ciphertext, 'ciphertext'); @@ -89,6 +92,7 @@ function decapsulate(key, ciphertext, callback) { keyFormat, keyType, keyPassphrase, + keyNamedCurve, ciphertext); if (!callback) { diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 90f55a88a346b5..d66f03a4ebcea7 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -46,7 +46,6 @@ const { const { codes: { ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS, - ERR_CRYPTO_INVALID_JWK, ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE, ERR_ILLEGAL_CONSTRUCTOR, ERR_INVALID_ARG_TYPE, @@ -84,8 +83,6 @@ const { const { inspect } = require('internal/util/inspect'); -const { Buffer } = require('buffer'); - const kAlgorithm = Symbol('kAlgorithm'); const kExtractable = Symbol('kExtractable'); const kKeyType = Symbol('kKeyType'); @@ -630,225 +627,6 @@ function getKeyTypes(allowKeyObject, bufferOnly = false) { return types; } -function mlDsaPubLen(alg) { - switch (alg) { - case 'ML-DSA-44': return 1312; - case 'ML-DSA-65': return 1952; - case 'ML-DSA-87': return 2592; - } -} - -function getKeyObjectHandleFromJwk(key, ctx) { - validateObject(key, 'key'); - validateOneOf( - key.kty, 'key.kty', ['RSA', 'EC', 'OKP', 'AKP']); - const isPublic = ctx === kConsumePublic || ctx === kCreatePublic; - - if (key.kty === 'AKP') { - validateOneOf( - key.alg, 'key.alg', ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']); - validateString(key.pub, 'key.pub'); - - let keyData; - if (isPublic) { - keyData = Buffer.from(key.pub, 'base64url'); - if (keyData.byteLength !== mlDsaPubLen(key.alg)) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - } else { - validateString(key.priv, 'key.priv'); - keyData = Buffer.from(key.priv, 'base64url'); - if (keyData.byteLength !== 32) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - } - - const handle = new KeyObjectHandle(); - - const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initPqcRaw(key.alg, keyData, keyType)) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - - return handle; - } - - if (key.kty === 'OKP') { - validateString(key.crv, 'key.crv'); - validateOneOf( - key.crv, 'key.crv', ['Ed25519', 'Ed448', 'X25519', 'X448']); - validateString(key.x, 'key.x'); - - if (!isPublic) - validateString(key.d, 'key.d'); - - let keyData; - if (isPublic) - keyData = Buffer.from(key.x, 'base64'); - else - keyData = Buffer.from(key.d, 'base64'); - - switch (key.crv) { - case 'Ed25519': - case 'X25519': - if (keyData.byteLength !== 32) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - break; - case 'Ed448': - if (keyData.byteLength !== 57) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - break; - case 'X448': - if (keyData.byteLength !== 56) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - break; - } - - const handle = new KeyObjectHandle(); - - const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initEDRaw(key.crv, keyData, keyType)) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - - return handle; - } - - if (key.kty === 'EC') { - validateString(key.crv, 'key.crv'); - validateOneOf( - key.crv, 'key.crv', ['P-256', 'secp256k1', 'P-384', 'P-521']); - validateString(key.x, 'key.x'); - validateString(key.y, 'key.y'); - - const jwk = { - kty: key.kty, - crv: key.crv, - x: key.x, - y: key.y, - }; - - if (!isPublic) { - validateString(key.d, 'key.d'); - jwk.d = key.d; - } - - const handle = new KeyObjectHandle(); - const type = handle.initJwk(jwk, jwk.crv); - if (type === undefined) - throw new ERR_CRYPTO_INVALID_JWK(); - - return handle; - } - - // RSA - validateString(key.n, 'key.n'); - validateString(key.e, 'key.e'); - - const jwk = { - kty: key.kty, - n: key.n, - e: key.e, - }; - - if (!isPublic) { - validateString(key.d, 'key.d'); - validateString(key.p, 'key.p'); - validateString(key.q, 'key.q'); - validateString(key.dp, 'key.dp'); - validateString(key.dq, 'key.dq'); - validateString(key.qi, 'key.qi'); - jwk.d = key.d; - jwk.p = key.p; - jwk.q = key.q; - jwk.dp = key.dp; - jwk.dq = key.dq; - jwk.qi = key.qi; - } - - const handle = new KeyObjectHandle(); - const type = handle.initJwk(jwk); - if (type === undefined) - throw new ERR_CRYPTO_INVALID_JWK(); - - return handle; -} - - -function getKeyObjectHandleFromRaw(options, data, format) { - if (!isStringOrBuffer(data)) { - throw new ERR_INVALID_ARG_TYPE( - 'key.key', - ['ArrayBuffer', 'Buffer', 'TypedArray', 'DataView'], - data); - } - - const keyData = getArrayBufferOrView(data, 'key.key'); - - validateString(options.asymmetricKeyType, 'key.asymmetricKeyType'); - const asymmetricKeyType = options.asymmetricKeyType; - - const handle = new KeyObjectHandle(); - - switch (asymmetricKeyType) { - case 'ec': { - validateString(options.namedCurve, 'key.namedCurve'); - if (format === 'raw-public') { - if (!handle.initECRaw(options.namedCurve, keyData)) { - throw new ERR_INVALID_ARG_VALUE('key.key', keyData); - } - } else if (!handle.initECPrivateRaw(options.namedCurve, keyData)) { - throw new ERR_INVALID_ARG_VALUE('key.key', keyData); - } - return handle; - } - case 'ed25519': - case 'ed448': - case 'x25519': - case 'x448': { - const keyType = format === 'raw-public' ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initEDRaw(asymmetricKeyType, keyData, keyType)) { - throw new ERR_INVALID_ARG_VALUE('key.key', keyData); - } - return handle; - } - case 'rsa': - case 'rsa-pss': - case 'dsa': - case 'dh': - throw new ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS( - format, `is not supported for ${asymmetricKeyType} keys`); - case 'ml-dsa-44': - case 'ml-dsa-65': - case 'ml-dsa-87': - case 'ml-kem-512': - case 'ml-kem-768': - case 'ml-kem-1024': - case 'slh-dsa-sha2-128f': - case 'slh-dsa-sha2-128s': - case 'slh-dsa-sha2-192f': - case 'slh-dsa-sha2-192s': - case 'slh-dsa-sha2-256f': - case 'slh-dsa-sha2-256s': - case 'slh-dsa-shake-128f': - case 'slh-dsa-shake-128s': - case 'slh-dsa-shake-192f': - case 'slh-dsa-shake-192s': - case 'slh-dsa-shake-256f': - case 'slh-dsa-shake-256s': { - const keyType = format === 'raw-public' ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initPqcRaw(asymmetricKeyType, keyData, keyType)) { - throw new ERR_INVALID_ARG_VALUE('key.key', keyData); - } - return handle; - } - default: - throw new ERR_INVALID_ARG_VALUE('asymmetricKeyType', asymmetricKeyType); - } -} function prepareAsymmetricKey(key, ctx) { if (isKeyObject(key)) { @@ -877,12 +655,25 @@ function prepareAsymmetricKey(key, ctx) { } if (format === 'jwk') { validateObject(data, 'key.key'); - return { data: getKeyObjectHandleFromJwk(data, ctx), format: 'jwk' }; + return { data, format: kKeyFormatJWK }; } else if (format === 'raw-public' || format === 'raw-private' || format === 'raw-seed') { + if (!isStringOrBuffer(data)) { + throw new ERR_INVALID_ARG_TYPE( + 'key.key', + ['ArrayBuffer', 'Buffer', 'TypedArray', 'DataView'], + data); + } + validateString(key.asymmetricKeyType, 'key.asymmetricKeyType'); + if (key.asymmetricKeyType === 'ec') { + validateString(key.namedCurve, 'key.namedCurve'); + } + const rawFormat = parseKeyFormat(format, undefined, 'options.format'); return { - data: getKeyObjectHandleFromRaw(key, data, format), - format, + data: getArrayBufferOrView(data, 'key.key'), + format: rawFormat, + type: key.asymmetricKeyType, + namedCurve: key.namedCurve ?? null, }; } @@ -949,28 +740,20 @@ function createSecretKey(key, encoding) { } function createPublicKey(key) { - const { format, type, data, passphrase } = + const { format, type, data, passphrase, namedCurve } = prepareAsymmetricKey(key, kCreatePublic); - let handle; - if (format === 'jwk' || format === 'raw-public') { - handle = data; - } else { - handle = new KeyObjectHandle(); - handle.init(kKeyTypePublic, data, format, type, passphrase); - } + const handle = new KeyObjectHandle(); + handle.init(kKeyTypePublic, data, format ?? null, + type ?? null, passphrase ?? null, namedCurve ?? null); return new PublicKeyObject(handle); } function createPrivateKey(key) { - const { format, type, data, passphrase } = + const { format, type, data, passphrase, namedCurve } = prepareAsymmetricKey(key, kCreatePrivate); - let handle; - if (format === 'jwk' || format === 'raw-private' || format === 'raw-seed') { - handle = data; - } else { - handle = new KeyObjectHandle(); - handle.init(kKeyTypePrivate, data, format, type, passphrase); - } + const handle = new KeyObjectHandle(); + handle.init(kKeyTypePrivate, data, format ?? null, + type ?? null, passphrase ?? null, namedCurve ?? null); return new PrivateKeyObject(handle); } diff --git a/lib/internal/crypto/mac.js b/lib/internal/crypto/mac.js index 1ad4e27c6a8d39..1689d19d4a0660 100644 --- a/lib/internal/crypto/mac.js +++ b/lib/internal/crypto/mac.js @@ -11,6 +11,8 @@ const { KeyObjectHandle, KmacJob, kCryptoJobAsync, + kKeyFormatJWK, + kKeyTypeSecret, kSignJobModeSign, kSignJobModeVerify, } = internalBinding('crypto'); @@ -20,7 +22,6 @@ const { hasAnyNotIn, jobPromise, normalizeHashName, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -48,6 +49,10 @@ const { kAlgorithm, } = require('internal/crypto/keys'); +const { + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const generateKey = promisify(_generateKey); async function hmacGenerateKey(algorithm, extractable, keyUsages) { @@ -143,27 +148,7 @@ function macImportKey( break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - - if (keyData.kty !== 'oct') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); - - if (usagesSet.size > 0 && - keyData.use !== undefined && - keyData.use !== 'sig') { - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } + validateJwk(keyData, 'oct', extractable, usagesSet, 'sig'); if (keyData.alg !== undefined) { const expected = isHmac ? @@ -177,7 +162,7 @@ function macImportKey( const handle = new KeyObjectHandle(); try { - handle.initJwk(keyData); + handle.init(kKeyTypeSecret, keyData, kKeyFormatJWK, null, null); } catch (err) { throw lazyDOMException( 'Invalid keyData', { name: 'DataError', cause: err }); diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index 4d90a80f0200f7..e6c70db034275f 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -7,33 +7,23 @@ const { TypedArrayPrototypeGetByteLength, } = primordials; -const { Buffer } = require('buffer'); - const { - KeyObjectHandle, SignJob, kCryptoJobAsync, - kKeyTypePrivate, - kKeyTypePublic, + kKeyFormatDER, + kKeyFormatRawPublic, + kKeyFormatRawSeed, kSignJobModeSign, kSignJobModeVerify, - kKeyFormatDER, kWebCryptoKeyFormatRaw, kWebCryptoKeyFormatPKCS8, kWebCryptoKeyFormatSPKI, } = internalBinding('crypto'); -const { - codes: { - ERR_CRYPTO_INVALID_JWK, - }, -} = require('internal/errors'); - const { getUsagesUnion, hasAnyNotIn, jobPromise, - validateKeyOps, kHandle, kKeyObject, } = require('internal/crypto/util'); @@ -49,13 +39,16 @@ const { const { InternalCryptoKey, - PrivateKeyObject, - PublicKeyObject, - createPrivateKey, - createPublicKey, kKeyType, } = require('internal/crypto/keys'); +const { + importDerKey, + importJwkKey, + importRawKey, + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const generateKeyPair = promisify(_generateKeyPair); function verifyAcceptableMlDsaKeyUse(name, isPublic, usages) { @@ -67,16 +60,6 @@ function verifyAcceptableMlDsaKeyUse(name, isPublic, usages) { } } -function createMlDsaRawKey(name, keyData, isPublic) { - const handle = new KeyObjectHandle(); - const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initPqcRaw(name, keyData, keyType)) { - throw lazyDOMException('Invalid keyData', 'DataError'); - } - - return isPublic ? new PublicKeyObject(handle) : new PrivateKeyObject(handle); -} - async function mlDsaGenerateKey(algorithm, extractable, keyUsages) { const { name } = algorithm; @@ -169,16 +152,7 @@ function mlDsaImportKey( } case 'spki': { verifyAcceptableMlDsaKeyUse(name, true, usagesSet); - try { - keyObject = createPublicKey({ - key: keyData, - format: 'der', - type: 'spki', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, true); break; } case 'pkcs8': { @@ -196,85 +170,26 @@ function mlDsaImportKey( 'NotSupportedError'); } - try { - keyObject = createPrivateKey({ - key: keyData, - format: 'der', - type: 'pkcs8', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, false); break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - if (keyData.kty !== 'AKP') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); + validateJwk(keyData, 'AKP', extractable, usagesSet, 'sig'); + if (keyData.alg !== name) throw lazyDOMException( 'JWK "alg" Parameter and algorithm name mismatch', 'DataError'); - const isPublic = keyData.priv === undefined; - if (usagesSet.size > 0 && keyData.use !== undefined) { - if (keyData.use !== 'sig') - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } - - if (!isPublic && typeof keyData.pub !== 'string') { - throw lazyDOMException('Invalid JWK', 'DataError'); - } - - verifyAcceptableMlDsaKeyUse( - name, - isPublic, - usagesSet); - - try { - const publicKeyObject = createMlDsaRawKey( - name, - Buffer.from(keyData.pub, 'base64url'), - true); - - if (isPublic) { - keyObject = publicKeyObject; - } else { - keyObject = createMlDsaRawKey( - name, - Buffer.from(keyData.priv, 'base64url'), - false); - - if (!createPublicKey(keyObject).equals(publicKeyObject)) { - throw new ERR_CRYPTO_INVALID_JWK(); - } - } - } catch (err) { - throw lazyDOMException('Invalid keyData', { name: 'DataError', cause: err }); - } + const isPublic = keyData.priv === undefined; + verifyAcceptableMlDsaKeyUse(name, isPublic, usagesSet); + keyObject = importJwkKey(isPublic, keyData); break; } case 'raw-public': case 'raw-seed': { const isPublic = format === 'raw-public'; verifyAcceptableMlDsaKeyUse(name, isPublic, usagesSet); - - try { - keyObject = createMlDsaRawKey(name, keyData, isPublic); - } catch (err) { - throw lazyDOMException('Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importRawKey(isPublic, keyData, isPublic ? kKeyFormatRawPublic : kKeyFormatRawSeed, name); break; } default: @@ -306,6 +221,7 @@ async function mlDsaSignVerify(key, data, algorithm, signature) { undefined, undefined, undefined, + undefined, data, undefined, undefined, diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index 0a82ca8453b8fe..f62d596ccdfc1b 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -12,10 +12,9 @@ const { kCryptoJobAsync, KEMDecapsulateJob, KEMEncapsulateJob, - KeyObjectHandle, kKeyFormatDER, - kKeyTypePrivate, - kKeyTypePublic, + kKeyFormatRawPrivate, + kKeyFormatRawPublic, kWebCryptoKeyFormatPKCS8, kWebCryptoKeyFormatRaw, kWebCryptoKeyFormatSPKI, @@ -39,13 +38,14 @@ const { const { InternalCryptoKey, - PrivateKeyObject, - PublicKeyObject, - createPrivateKey, - createPublicKey, kKeyType, } = require('internal/crypto/keys'); +const { + importDerKey, + importRawKey, +} = require('internal/crypto/webcrypto_util'); + const generateKeyPair = promisify(_generateKeyPair); async function mlKemGenerateKey(algorithm, extractable, keyUsages) { @@ -131,16 +131,6 @@ function verifyAcceptableMlKemKeyUse(name, isPublic, usages) { } } -function createMlKemRawKey(name, keyData, isPublic) { - const handle = new KeyObjectHandle(); - const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; - if (!handle.initPqcRaw(name, keyData, keyType)) { - throw lazyDOMException('Invalid keyData', 'DataError'); - } - - return isPublic ? new PublicKeyObject(handle) : new PrivateKeyObject(handle); -} - function mlKemImportKey( format, keyData, @@ -159,16 +149,7 @@ function mlKemImportKey( } case 'spki': { verifyAcceptableMlKemKeyUse(name, true, usagesSet); - try { - keyObject = createPublicKey({ - key: keyData, - format: 'der', - type: 'spki', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, true); break; } case 'pkcs8': { @@ -186,28 +167,14 @@ function mlKemImportKey( 'NotSupportedError'); } - try { - keyObject = createPrivateKey({ - key: keyData, - format: 'der', - type: 'pkcs8', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, false); break; } case 'raw-public': case 'raw-seed': { const isPublic = format === 'raw-public'; verifyAcceptableMlKemKeyUse(name, isPublic, usagesSet); - - try { - keyObject = createMlKemRawKey(name, keyData, isPublic); - } catch (err) { - throw lazyDOMException('Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importRawKey(isPublic, keyData, isPublic ? kKeyFormatRawPublic : kKeyFormatRawPrivate, name); break; } default: @@ -237,6 +204,7 @@ function mlKemEncapsulate(encapsulationKey) { encapsulationKey[kKeyObject][kHandle], undefined, undefined, + undefined, undefined); job.ondone = (error, result) => { @@ -271,6 +239,7 @@ function mlKemDecapsulate(decapsulationKey, ciphertext) { undefined, undefined, undefined, + undefined, ciphertext); job.ondone = (error, result) => { diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index 52c1257f48b0c6..b87ef49dbd5aa4 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -8,7 +8,6 @@ const { } = primordials; const { - KeyObjectHandle, RSACipherJob, SignJob, kCryptoJobAsync, @@ -16,7 +15,6 @@ const { kSignJobModeSign, kSignJobModeVerify, kKeyVariantRSA_OAEP, - kKeyTypePrivate, kWebCryptoCipherEncrypt, kWebCryptoKeyFormatPKCS8, kWebCryptoKeyFormatSPKI, @@ -34,7 +32,6 @@ const { hasAnyNotIn, jobPromise, normalizeHashName, - validateKeyOps, validateMaxBufferLength, kHandle, kKeyObject, @@ -47,14 +44,16 @@ const { const { InternalCryptoKey, - PrivateKeyObject, - PublicKeyObject, - createPublicKey, - createPrivateKey, kAlgorithm, kKeyType, } = require('internal/crypto/keys'); +const { + importDerKey, + importJwkKey, + validateJwk, +} = require('internal/crypto/webcrypto_util'); + const { generateKeyPair: _generateKeyPair, } = require('internal/crypto/keygen'); @@ -234,59 +233,17 @@ function rsaImportKey( } case 'spki': { verifyAcceptableRsaKeyUse(algorithm.name, true, usagesSet); - try { - keyObject = createPublicKey({ - key: keyData, - format: 'der', - type: 'spki', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, true); break; } case 'pkcs8': { verifyAcceptableRsaKeyUse(algorithm.name, false, usagesSet); - try { - keyObject = createPrivateKey({ - key: keyData, - format: 'der', - type: 'pkcs8', - }); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } + keyObject = importDerKey(keyData, false); break; } case 'jwk': { - if (!keyData.kty) - throw lazyDOMException('Invalid keyData', 'DataError'); - - if (keyData.kty !== 'RSA') - throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); - - verifyAcceptableRsaKeyUse( - algorithm.name, - keyData.d === undefined, - usagesSet); - - if (usagesSet.size > 0 && keyData.use !== undefined) { - const checkUse = algorithm.name === 'RSA-OAEP' ? 'enc' : 'sig'; - if (keyData.use !== checkUse) - throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); - } - - validateKeyOps(keyData.key_ops, usagesSet); - - if (keyData.ext !== undefined && - keyData.ext === false && - extractable === true) { - throw lazyDOMException( - 'JWK "ext" Parameter and extractable mismatch', - 'DataError'); - } + const expectedUse = algorithm.name === 'RSA-OAEP' ? 'enc' : 'sig'; + validateJwk(keyData, 'RSA', extractable, usagesSet, expectedUse); if (keyData.alg !== undefined) { const expected = @@ -301,21 +258,9 @@ function rsaImportKey( 'DataError'); } - const handle = new KeyObjectHandle(); - let type; - try { - type = handle.initJwk(keyData); - } catch (err) { - throw lazyDOMException( - 'Invalid keyData', { name: 'DataError', cause: err }); - } - if (type === undefined) - throw lazyDOMException('Invalid keyData', 'DataError'); - - keyObject = type === kKeyTypePrivate ? - new PrivateKeyObject(handle) : - new PublicKeyObject(handle); - + const isPublic = keyData.d === undefined; + verifyAcceptableRsaKeyUse(algorithm.name, isPublic, usagesSet); + keyObject = importJwkKey(isPublic, keyData); break; } default: @@ -362,6 +307,7 @@ async function rsaSignVerify(key, data, { saltLength }, signature) { undefined, undefined, undefined, + undefined, data, normalizeHashName(key[kAlgorithm].hash.name), saltLength, diff --git a/lib/internal/crypto/sig.js b/lib/internal/crypto/sig.js index 7d38c0bdf60687..324b804a817a3d 100644 --- a/lib/internal/crypto/sig.js +++ b/lib/internal/crypto/sig.js @@ -134,7 +134,8 @@ Sign.prototype.sign = function sign(options, encoding) { if (!options) throw new ERR_CRYPTO_SIGN_KEY_REQUIRED(); - const { data, format, type, passphrase } = preparePrivateKey(options, true); + const { data, format, type, passphrase, namedCurve } = + preparePrivateKey(options, true); // Options specific to RSA const rsaPadding = getPadding(options); @@ -143,7 +144,9 @@ Sign.prototype.sign = function sign(options, encoding) { // Options specific to (EC)DSA const dsaSigEnc = getDSASignatureEncoding(options); - const ret = this[kHandle].sign(data, format, type, passphrase, rsaPadding, + const ret = this[kHandle].sign(data, format, type, + passphrase, namedCurve, + rsaPadding, pssSaltLength, dsaSigEnc); if (encoding && encoding !== 'buffer') @@ -179,6 +182,7 @@ function signOneShot(algorithm, data, key, callback) { format: keyFormat, type: keyType, passphrase: keyPassphrase, + namedCurve: keyNamedCurve, } = preparePrivateKey(key); const job = new SignJob( @@ -188,6 +192,7 @@ function signOneShot(algorithm, data, key, callback) { keyFormat, keyType, keyPassphrase, + keyNamedCurve, data, algorithm, pssSaltLength, @@ -233,6 +238,7 @@ Verify.prototype.verify = function verify(options, signature, sigEncoding) { format, type, passphrase, + namedCurve, } = preparePublicOrPrivateKey(options, true); // Options specific to RSA @@ -244,7 +250,9 @@ Verify.prototype.verify = function verify(options, signature, sigEncoding) { signature = getArrayBufferOrView(signature, 'signature', sigEncoding); - return this[kHandle].verify(data, format, type, passphrase, signature, + return this[kHandle].verify(data, format, type, + passphrase, namedCurve, + signature, rsaPadding, pssSaltLength, dsaSigEnc); }; @@ -288,6 +296,7 @@ function verifyOneShot(algorithm, data, key, signature, callback) { format: keyFormat, type: keyType, passphrase: keyPassphrase, + namedCurve: keyNamedCurve, } = preparePublicOrPrivateKey(key); const job = new SignJob( @@ -297,6 +306,7 @@ function verifyOneShot(algorithm, data, key, signature, callback) { keyFormat, keyType, keyPassphrase, + keyNamedCurve, data, algorithm, pssSaltLength, diff --git a/lib/internal/crypto/webcrypto_util.js b/lib/internal/crypto/webcrypto_util.js new file mode 100644 index 00000000000000..db340fa620c35a --- /dev/null +++ b/lib/internal/crypto/webcrypto_util.js @@ -0,0 +1,93 @@ +'use strict'; + +const { + KeyObjectHandle, + kKeyFormatDER, + kKeyFormatJWK, + kKeyEncodingPKCS8, + kKeyEncodingSPKI, + kKeyTypePublic, + kKeyTypePrivate, +} = internalBinding('crypto'); + +const { + validateKeyOps, +} = require('internal/crypto/util'); + +const { + lazyDOMException, +} = require('internal/util'); + +const { + PrivateKeyObject, + PublicKeyObject, +} = require('internal/crypto/keys'); + +function importDerKey(keyData, isPublic) { + const handle = new KeyObjectHandle(); + const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; + const encoding = isPublic ? kKeyEncodingSPKI : kKeyEncodingPKCS8; + try { + handle.init(keyType, keyData, kKeyFormatDER, encoding, null, null); + } catch (err) { + throw lazyDOMException( + 'Invalid keyData', { name: 'DataError', cause: err }); + } + return isPublic ? + new PublicKeyObject(handle) : + new PrivateKeyObject(handle); +} + +function validateJwk(keyData, kty, extractable, usagesSet, expectedUse) { + if (!keyData.kty) + throw lazyDOMException('Invalid keyData', 'DataError'); + if (keyData.kty !== kty) + throw lazyDOMException('Invalid JWK "kty" Parameter', 'DataError'); + if (usagesSet.size > 0 && keyData.use !== undefined) { + if (keyData.use !== expectedUse) + throw lazyDOMException('Invalid JWK "use" Parameter', 'DataError'); + } + validateKeyOps(keyData.key_ops, usagesSet); + if (keyData.ext !== undefined && + keyData.ext === false && + extractable === true) { + throw lazyDOMException( + 'JWK "ext" Parameter and extractable mismatch', + 'DataError'); + } +} + +function importJwkKey(isPublic, keyData) { + const handle = new KeyObjectHandle(); + const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; + try { + handle.init(keyType, keyData, kKeyFormatJWK, null, null, null); + } catch (err) { + throw lazyDOMException( + 'Invalid keyData', { name: 'DataError', cause: err }); + } + return isPublic ? + new PublicKeyObject(handle) : + new PrivateKeyObject(handle); +} + +function importRawKey(isPublic, keyData, format, name, namedCurve) { + const handle = new KeyObjectHandle(); + const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate; + try { + handle.init(keyType, keyData, format, name ?? null, null, namedCurve ?? null); + } catch (err) { + throw lazyDOMException( + 'Invalid keyData', { name: 'DataError', cause: err }); + } + return isPublic ? + new PublicKeyObject(handle) : + new PrivateKeyObject(handle); +} + +module.exports = { + importDerKey, + importJwkKey, + importRawKey, + validateJwk, +}; diff --git a/src/crypto/crypto_ec.cc b/src/crypto/crypto_ec.cc index ee0de59f7be592..6738edd590c300 100644 --- a/src/crypto/crypto_ec.cc +++ b/src/crypto/crypto_ec.cc @@ -690,14 +690,95 @@ bool ExportJWKEdKey(Environment* env, target->Set(env->context(), env->jwk_kty_string(), env->jwk_okp_string()) .IsNothing()); } +KeyObjectData ImportJWKEdKey(Environment* env, Local jwk) { + Local crv_value; + Local x_value; + Local d_value; + + if (!jwk->Get(env->context(), env->jwk_crv_string()).ToLocal(&crv_value) || + !jwk->Get(env->context(), env->jwk_x_string()).ToLocal(&x_value) || + !jwk->Get(env->context(), env->jwk_d_string()).ToLocal(&d_value)) { + return {}; + } + + if (!crv_value->IsString() || !x_value->IsString() || + (!d_value->IsUndefined() && !d_value->IsString())) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK OKP key"); + return {}; + } + + Utf8Value crv(env->isolate(), crv_value.As()); + + static constexpr struct { + const char* name; + int nid; + } kCurveToNid[] = { + {"Ed25519", EVP_PKEY_ED25519}, + {"Ed448", EVP_PKEY_ED448}, + {"X25519", EVP_PKEY_X25519}, + {"X448", EVP_PKEY_X448}, + }; + + int id = NID_undef; + for (const auto& entry : kCurveToNid) { + if (strcmp(*crv, entry.name) == 0) { + id = entry.nid; + break; + } + } + + if (id == NID_undef) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK OKP key"); + return {}; + } + + KeyType type = d_value->IsString() ? kKeyTypePrivate : kKeyTypePublic; + + ByteSource raw; + if (type == kKeyTypePrivate) { + raw = ByteSource::FromEncodedString(env, d_value.As()); + } else { + raw = ByteSource::FromEncodedString(env, x_value.As()); + } + + typedef EVPKeyPointer (*new_key_fn)( + int, const ncrypto::Buffer&); + new_key_fn fn = type == kKeyTypePrivate ? EVPKeyPointer::NewRawPrivate + : EVPKeyPointer::NewRawPublic; + + auto pkey = fn(id, + ncrypto::Buffer{ + .data = raw.data(), + .len = raw.size(), + }); + if (!pkey) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK OKP key"); + return {}; + } -KeyObjectData ImportJWKEcKey(Environment* env, - Local jwk, - const FunctionCallbackInfo& args, - unsigned int offset) { - CHECK(args[offset]->IsString()); // curve name - Utf8Value curve(env->isolate(), args[offset].As()); + // When importing a private key, verify that the JWK's x field matches + // the public key derived from the private key. + if (type == kKeyTypePrivate && x_value->IsString()) { + ByteSource x = ByteSource::FromEncodedString(env, x_value.As()); + auto derived_pub = pkey.rawPublicKey(); + if (!derived_pub || derived_pub.size() != x.size() || + CRYPTO_memcmp(derived_pub.get(), x.data(), x.size()) != 0) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK OKP key"); + return {}; + } + } + return KeyObjectData::CreateAsymmetric(type, std::move(pkey)); +} +KeyObjectData ImportJWKEcKey(Environment* env, Local jwk) { + Local crv_value; + if (!jwk->Get(env->context(), env->jwk_crv_string()).ToLocal(&crv_value) || + !crv_value->IsString()) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK EC key"); + return {}; + } + + Utf8Value curve(env->isolate(), crv_value.As()); int nid = Ec::GetCurveIdFromName(*curve); if (nid == NID_undef) { // Unknown curve THROW_ERR_CRYPTO_INVALID_CURVE(env); @@ -732,6 +813,8 @@ KeyObjectData ImportJWKEcKey(Environment* env, ByteSource x = ByteSource::FromEncodedString(env, x_value.As()); ByteSource y = ByteSource::FromEncodedString(env, y_value.As()); + // setPublicKeyRaw validates the point is on the curve. For h=1 curves + // (P-256/P-384/P-521), this skips EC_KEY_check_key for efficiency. if (!ec.setPublicKeyRaw(x.ToBN(), y.ToBN())) { THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK EC key"); return {}; @@ -743,6 +826,11 @@ KeyObjectData ImportJWKEcKey(Environment* env, THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK EC key"); return {}; } + // Verify that the public point matches the private scalar (d*G == (x,y)). + if (!ec.checkKey()) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK EC key"); + return {}; + } } auto pkey = EVPKeyPointer::New(); diff --git a/src/crypto/crypto_ec.h b/src/crypto/crypto_ec.h index 0bfc7c3e35e16b..5522ac743e3089 100644 --- a/src/crypto/crypto_ec.h +++ b/src/crypto/crypto_ec.h @@ -123,10 +123,9 @@ bool ExportJWKEdKey(Environment* env, const KeyObjectData& key, v8::Local target); -KeyObjectData ImportJWKEcKey(Environment* env, - v8::Local jwk, - const v8::FunctionCallbackInfo& args, - unsigned int offset); +KeyObjectData ImportJWKEdKey(Environment* env, v8::Local jwk); + +KeyObjectData ImportJWKEcKey(Environment* env, v8::Local jwk); bool GetEcKeyDetail(Environment* env, const KeyObjectData& key, diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc index bcea72facc7ca9..d1a46a15d898ab 100644 --- a/src/crypto/crypto_keys.cc +++ b/src/crypto/crypto_keys.cc @@ -207,22 +207,6 @@ bool ExportJWKAsymmetricKey(Environment* env, return false; } -KeyObjectData ImportJWKAsymmetricKey(Environment* env, - Local jwk, - std::string_view kty, - const FunctionCallbackInfo& args, - unsigned int offset) { - if (kty == "RSA") { - return ImportJWKRsaKey(env, jwk, args, offset); - } else if (kty == "EC") { - return ImportJWKEcKey(env, jwk, args, offset); - } - - THROW_ERR_CRYPTO_INVALID_JWK( - env, "%s is not a supported JWK key type", kty.data()); - return {}; -} - bool GetSecretKeyDetail(Environment* env, const KeyObjectData& key, Local target) { @@ -555,12 +539,273 @@ KeyObjectData::GetPublicKeyEncodingFromJs( return GetKeyFormatAndTypeFromJs(args, offset, context); } +// Shared helper for importing raw asymmetric keys. Called from +// ImportRawKeyFromArgs. +static KeyObjectData ImportRawKey(Environment* env, + const unsigned char* key_data, + size_t key_data_len, + EVPKeyPointer::PKFormatType format, + Local key_type, + const char* key_type_name, + const char* named_curve, + KeyType target_type) { + auto throw_invalid = [&]() { + if (!env->isolate()->HasPendingException()) { + THROW_ERR_INVALID_ARG_VALUE(env, "Invalid key data"); + } + }; + + // EC keys + if (key_type->StringEquals(env->crypto_ec_string())) { + int curve_nid = ncrypto::Ec::GetCurveIdFromName(named_curve); + if (curve_nid == NID_undef) { + THROW_ERR_CRYPTO_INVALID_CURVE(env); + return {}; + } + auto eckey = ECKeyPointer::NewByCurveName(curve_nid); + if (!eckey) { + throw_invalid(); + return {}; + } + if (format == EVPKeyPointer::PKFormatType::RAW_PUBLIC) { + const auto group = eckey.getGroup(); + auto pub = ECPointPointer::New(group); + if (!pub) { + throw_invalid(); + return {}; + } + ncrypto::Buffer buffer{ + .data = key_data, + .len = key_data_len, + }; + if (!pub.setFromBuffer(buffer, group) || !eckey.setPublicKey(pub)) { + throw_invalid(); + return {}; + } + } else { + const auto group = eckey.getGroup(); + auto order = BignumPointer::New(); + CHECK(order); + CHECK(EC_GROUP_get_order(group, order.get(), nullptr)); + if (key_data_len != order.byteLength()) { + throw_invalid(); + return {}; + } + BignumPointer priv_bn(key_data, key_data_len); + if (!priv_bn || !eckey.setPrivateKey(priv_bn)) { + throw_invalid(); + return {}; + } + auto pub_point = ECPointPointer::New(group); + if (!pub_point || !pub_point.mul(group, priv_bn.get()) || + !eckey.setPublicKey(pub_point)) { + throw_invalid(); + return {}; + } + } + auto pkey = EVPKeyPointer::New(); + if (!pkey.assign(eckey)) { + throw_invalid(); + return {}; + } + eckey.release(); + return KeyObjectData::CreateAsymmetric(target_type, std::move(pkey)); + } + + int id = GetNidFromName(key_type_name); + + typedef EVPKeyPointer (*new_key_fn)( + int, const ncrypto::Buffer&); + new_key_fn fn = nullptr; + switch (id) { + case EVP_PKEY_X25519: + case EVP_PKEY_X448: + case EVP_PKEY_ED25519: + case EVP_PKEY_ED448: + fn = target_type == kKeyTypePrivate ? EVPKeyPointer::NewRawPrivate + : EVPKeyPointer::NewRawPublic; + break; +#if OPENSSL_WITH_PQC + case EVP_PKEY_ML_DSA_44: + case EVP_PKEY_ML_DSA_65: + case EVP_PKEY_ML_DSA_87: + case EVP_PKEY_ML_KEM_512: + case EVP_PKEY_ML_KEM_768: + case EVP_PKEY_ML_KEM_1024: + fn = target_type == kKeyTypePrivate ? EVPKeyPointer::NewRawSeed + : EVPKeyPointer::NewRawPublic; + break; + case EVP_PKEY_SLH_DSA_SHA2_128F: + case EVP_PKEY_SLH_DSA_SHA2_128S: + case EVP_PKEY_SLH_DSA_SHA2_192F: + case EVP_PKEY_SLH_DSA_SHA2_192S: + case EVP_PKEY_SLH_DSA_SHA2_256F: + case EVP_PKEY_SLH_DSA_SHA2_256S: + case EVP_PKEY_SLH_DSA_SHAKE_128F: + case EVP_PKEY_SLH_DSA_SHAKE_128S: + case EVP_PKEY_SLH_DSA_SHAKE_192F: + case EVP_PKEY_SLH_DSA_SHAKE_192S: + case EVP_PKEY_SLH_DSA_SHAKE_256F: + case EVP_PKEY_SLH_DSA_SHAKE_256S: + fn = target_type == kKeyTypePrivate ? EVPKeyPointer::NewRawPrivate + : EVPKeyPointer::NewRawPublic; + break; +#endif + default: + break; + } + + if (fn != nullptr) { + auto pkey = fn(id, + ncrypto::Buffer{ + .data = key_data, + .len = key_data_len, + }); + if (!pkey) { + throw_invalid(); + return {}; + } + return KeyObjectData::CreateAsymmetric(target_type, std::move(pkey)); + } + + if (key_type->StringEquals(env->crypto_rsa_string()) || + key_type->StringEquals(env->crypto_rsa_pss_string()) || + key_type->StringEquals(env->crypto_dsa_string()) || + key_type->StringEquals(env->crypto_dh_string())) { + THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + return {}; + } + +#if !OPENSSL_WITH_PQC + if (key_type->StringEquals(env->crypto_ml_dsa_44_string()) || + key_type->StringEquals(env->crypto_ml_dsa_65_string()) || + key_type->StringEquals(env->crypto_ml_dsa_87_string()) || + key_type->StringEquals(env->crypto_ml_kem_512_string()) || + key_type->StringEquals(env->crypto_ml_kem_768_string()) || + key_type->StringEquals(env->crypto_ml_kem_1024_string()) || + key_type->StringEquals(env->crypto_slh_dsa_sha2_128f_string()) || + key_type->StringEquals(env->crypto_slh_dsa_sha2_128s_string()) || + key_type->StringEquals(env->crypto_slh_dsa_sha2_192f_string()) || + key_type->StringEquals(env->crypto_slh_dsa_sha2_192s_string()) || + key_type->StringEquals(env->crypto_slh_dsa_sha2_256f_string()) || + key_type->StringEquals(env->crypto_slh_dsa_sha2_256s_string()) || + key_type->StringEquals(env->crypto_slh_dsa_shake_128f_string()) || + key_type->StringEquals(env->crypto_slh_dsa_shake_128s_string()) || + key_type->StringEquals(env->crypto_slh_dsa_shake_192f_string()) || + key_type->StringEquals(env->crypto_slh_dsa_shake_192s_string()) || + key_type->StringEquals(env->crypto_slh_dsa_shake_256f_string()) || + key_type->StringEquals(env->crypto_slh_dsa_shake_256s_string())) { + THROW_ERR_INVALID_ARG_VALUE(env, "Unsupported key type"); + return {}; + } +#endif + + THROW_ERR_INVALID_ARG_VALUE( + env, "Invalid asymmetricKeyType: %s", key_type_name); + return {}; +} + +// Shared helper for importing a JWK asymmetric key. Extracts kty from the +// JWK object and dispatches to the appropriate importer. +static KeyObjectData ImportJWKFromArgs(Environment* env, Local jwk) { + Local kty; + if (!jwk->Get(env->context(), env->jwk_kty_string()).ToLocal(&kty) || + !kty->IsString()) { + THROW_ERR_CRYPTO_INVALID_JWK(env); + return {}; + } + Utf8Value kty_string(env->isolate(), kty); + if (*kty_string == std::string_view("RSA")) { + return ImportJWKRsaKey(env, jwk); + } else if (*kty_string == std::string_view("EC")) { + return ImportJWKEcKey(env, jwk); + } else if (*kty_string == std::string_view("OKP")) { + return ImportJWKEdKey(env, jwk); + } else if (*kty_string == std::string_view("AKP")) { +#if OPENSSL_WITH_PQC + return ImportJWKAkpKey(env, jwk); +#else + THROW_ERR_INVALID_ARG_VALUE(env, "Unsupported key type"); + return {}; +#endif + } + + THROW_ERR_CRYPTO_INVALID_JWK( + env, "%s is not a supported JWK key type", *kty_string); + return {}; +} + +// Shared helper for importing raw asymmetric keys from positional args. +// args layout: [... offset+0: buffer, offset+1: formatInt, +// offset+2: asymmetricKeyType, offset+3: passphrase, +// offset+4: namedCurve] +static KeyObjectData ImportRawKeyFromArgs( + const FunctionCallbackInfo& args, unsigned int offset) { + Environment* env = Environment::GetCurrent(args); + + auto format = static_cast( + args[offset + 1].As()->Value()); + KeyType type = (format == EVPKeyPointer::PKFormatType::RAW_PUBLIC) + ? kKeyTypePublic + : kKeyTypePrivate; + + ArrayBufferOrViewContents key_data(args[offset]); + if (!key_data.CheckSizeInt32()) [[unlikely]] { + THROW_ERR_OUT_OF_RANGE(env, "keyData is too big"); + return {}; + } + + CHECK(args[offset + 2]->IsString()); + Local key_type = args[offset + 2].As(); + Utf8Value key_type_name(env->isolate(), key_type); + + DCHECK_IMPLIES(key_type->StringEquals(env->crypto_ec_string()), + args[offset + 4]->IsString()); + Utf8Value curve(env->isolate(), + args[offset + 4]->IsString() ? args[offset + 4].As() + : String::Empty(env->isolate())); + + return ImportRawKey(env, + key_data.data(), + key_data.size(), + format, + key_type, + *key_type_name, + *curve, + type); +} + KeyObjectData KeyObjectData::GetPrivateKeyFromJs( const v8::FunctionCallbackInfo& args, unsigned int* offset, bool allow_key_object) { + Environment* env = Environment::GetCurrent(args); + + // JWK format: data is a JS Object (not buffer), format int is JWK. + if (args[*offset]->IsObject() && !IsAnyBufferSource(args[*offset]) && + args[*offset + 1]->IsInt32()) { + auto format = static_cast( + args[*offset + 1].As()->Value()); + if (format == EVPKeyPointer::PKFormatType::JWK) { + auto data = ImportJWKFromArgs(env, args[*offset].As()); + *offset += 5; + return data; + } + } + if (args[*offset]->IsString() || IsAnyBufferSource(args[*offset])) { - Environment* env = Environment::GetCurrent(args); + // Raw format: buffer + raw format int. + if (args[*offset + 1]->IsInt32()) { + auto format = static_cast( + args[*offset + 1].As()->Value()); + if (format == EVPKeyPointer::PKFormatType::RAW_PRIVATE || + format == EVPKeyPointer::PKFormatType::RAW_SEED) { + auto data = ImportRawKeyFromArgs(args, *offset); + *offset += 5; + return data; + } + } + auto key = ByteSource::FromStringOrBuffer(env, args[(*offset)++]); EVPKeyPointer::PrivateKeyEncodingConfig config; @@ -569,6 +814,9 @@ KeyObjectData KeyObjectData::GetPrivateKeyFromJs( return {}; } + // Skip the namedCurve argument (only used by raw format imports). + (*offset)++; + return TryParsePrivateKey( env, config, @@ -582,14 +830,40 @@ KeyObjectData KeyObjectData::GetPrivateKeyFromJs( KeyObjectHandle* key; ASSIGN_OR_RETURN_UNWRAP(&key, args[*offset].As(), KeyObjectData()); CHECK_EQ(key->Data().GetKeyType(), kKeyTypePrivate); - (*offset) += 4; + (*offset) += 5; return key->Data().addRef(); } KeyObjectData KeyObjectData::GetPublicOrPrivateKeyFromJs( const FunctionCallbackInfo& args, unsigned int* offset) { - if (IsAnyBufferSource(args[*offset])) { - Environment* env = Environment::GetCurrent(args); + Environment* env = Environment::GetCurrent(args); + + // JWK format: data is a JS Object (not buffer), format int is JWK. + if (args[*offset]->IsObject() && !IsAnyBufferSource(args[*offset]) && + args[*offset + 1]->IsInt32()) { + auto format = static_cast( + args[*offset + 1].As()->Value()); + if (format == EVPKeyPointer::PKFormatType::JWK) { + auto data = ImportJWKFromArgs(env, args[*offset].As()); + *offset += 5; + return data; + } + } + + if (args[*offset]->IsString() || IsAnyBufferSource(args[*offset])) { + // Raw format: buffer + raw format int. + if (args[*offset + 1]->IsInt32()) { + auto format = static_cast( + args[*offset + 1].As()->Value()); + if (format == EVPKeyPointer::PKFormatType::RAW_PUBLIC || + format == EVPKeyPointer::PKFormatType::RAW_PRIVATE || + format == EVPKeyPointer::PKFormatType::RAW_SEED) { + auto data = ImportRawKeyFromArgs(args, *offset); + *offset += 5; + return data; + } + } + ArrayBufferOrViewContents data(args[(*offset)++]); if (!data.CheckSizeInt32()) [[unlikely]] { THROW_ERR_OUT_OF_RANGE(env, "keyData is too big"); @@ -603,6 +877,9 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKeyFromJs( return {}; } + // Skip the namedCurve argument (only used by raw format imports). + (*offset)++; + ncrypto::Buffer buffer = { .data = reinterpret_cast(data.data()), .len = data.size(), @@ -661,7 +938,7 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKeyFromJs( BaseObject::Unwrap(args[*offset].As()); CHECK_NOT_NULL(key); CHECK_NE(key->Data().GetKeyType(), kKeyTypeSecret); - (*offset) += 4; + (*offset) += 5; return key->Data().addRef(); } @@ -783,18 +1060,13 @@ Local KeyObjectHandle::Initialize(Environment* env) { isolate, templ, "checkEcKeyData", CheckEcKeyData); SetProtoMethod(isolate, templ, "export", Export); SetProtoMethod(isolate, templ, "exportJwk", ExportJWK); - SetProtoMethod(isolate, templ, "initECRaw", InitECRaw); - SetProtoMethod(isolate, templ, "initEDRaw", InitEDRaw); SetProtoMethodNoSideEffect(isolate, templ, "rawPublicKey", RawPublicKey); SetProtoMethodNoSideEffect(isolate, templ, "rawPrivateKey", RawPrivateKey); - SetProtoMethod(isolate, templ, "initPqcRaw", InitPqcRaw); SetProtoMethodNoSideEffect(isolate, templ, "rawSeed", RawSeed); - SetProtoMethod(isolate, templ, "initECPrivateRaw", InitECPrivateRaw); SetProtoMethodNoSideEffect( isolate, templ, "exportECPublicRaw", ExportECPublicRaw); SetProtoMethodNoSideEffect( isolate, templ, "exportECPrivateRaw", ExportECPrivateRaw); - SetProtoMethod(isolate, templ, "initJwk", InitJWK); SetProtoMethod(isolate, templ, "keyDetail", GetKeyDetail); SetProtoMethod(isolate, templ, "equals", Equals); @@ -812,16 +1084,11 @@ void KeyObjectHandle::RegisterExternalReferences( registry->Register(CheckEcKeyData); registry->Register(Export); registry->Register(ExportJWK); - registry->Register(InitECRaw); - registry->Register(InitEDRaw); registry->Register(RawPublicKey); registry->Register(RawPrivateKey); - registry->Register(InitPqcRaw); registry->Register(RawSeed); - registry->Register(InitECPrivateRaw); registry->Register(ExportECPublicRaw); registry->Register(ExportECPrivateRaw); - registry->Register(InitJWK); registry->Register(GetKeyDetail); registry->Register(Equals); } @@ -862,6 +1129,7 @@ void KeyObjectHandle::Init(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&key, args.This()); MarkPopErrorOnReturn mark_pop_error_on_return; + Environment* env = Environment::GetCurrent(args); CHECK(args[0]->IsInt32()); KeyType type = static_cast(args[0].As()->Value()); @@ -869,25 +1137,70 @@ void KeyObjectHandle::Init(const FunctionCallbackInfo& args) { switch (type) { case kKeyTypeSecret: { + if (args.Length() == 5 && args[2]->IsInt32()) { + auto format = static_cast( + args[2].As()->Value()); + if (format == EVPKeyPointer::PKFormatType::JWK) { + CHECK(args[1]->IsObject()); + key->data_ = ImportJWKSecretKey(env, args[1].As()); + break; + } + } CHECK_EQ(args.Length(), 2); ArrayBufferOrViewContents buf(args[1]); key->data_ = KeyObjectData::CreateSecret(buf.ToCopy()); break; } - case kKeyTypePublic: { - CHECK_EQ(args.Length(), 5); - - offset = 1; - auto data = KeyObjectData::GetPublicOrPrivateKeyFromJs(args, &offset); - if (!data) return; - key->data_ = data.addRefWithType(kKeyTypePublic); - break; - } + case kKeyTypePublic: case kKeyTypePrivate: { - CHECK_EQ(args.Length(), 5); + CHECK_EQ(args.Length(), 6); + + // Check if this is a raw or JWK format import: + // args: [keyType, buffer/object, formatInt, typeString/null, + // passphrase/null, namedCurve/null] + if (args[2]->IsInt32()) { + auto format = static_cast( + args[2].As()->Value()); + if (format == EVPKeyPointer::PKFormatType::RAW_PUBLIC || + format == EVPKeyPointer::PKFormatType::RAW_PRIVATE || + format == EVPKeyPointer::PKFormatType::RAW_SEED) { + auto data = ImportRawKeyFromArgs(args, 1); + if (!data) return; + if (type == kKeyTypePublic && data.GetKeyType() == kKeyTypePrivate) { + key->data_ = data.addRefWithType(kKeyTypePublic); + } else { + key->data_ = std::move(data); + } + break; + } + if (format == EVPKeyPointer::PKFormatType::JWK) { + CHECK(args[1]->IsObject()); + key->data_ = ImportJWKFromArgs(env, args[1].As()); + if (!key->data_) return; + if (type == kKeyTypePublic && + key->data_.GetKeyType() == kKeyTypePrivate) { + key->data_ = key->data_.addRefWithType(kKeyTypePublic); + } else if (type == kKeyTypePrivate && + key->data_.GetKeyType() == kKeyTypePublic) { + THROW_ERR_CRYPTO_INVALID_JWK( + env, "JWK does not contain private key material"); + return; + } + args.GetReturnValue().Set(key->data_.GetKeyType()); + break; + } + } + offset = 1; - if (auto data = KeyObjectData::GetPrivateKeyFromJs(args, &offset, false)) { - key->data_ = std::move(data); + if (type == kKeyTypePublic) { + auto data = KeyObjectData::GetPublicOrPrivateKeyFromJs(args, &offset); + if (!data) return; + key->data_ = data.addRefWithType(kKeyTypePublic); + } else { + if (auto data = + KeyObjectData::GetPrivateKeyFromJs(args, &offset, false)) { + key->data_ = std::move(data); + } } break; } @@ -896,190 +1209,6 @@ void KeyObjectHandle::Init(const FunctionCallbackInfo& args) { } } -void KeyObjectHandle::InitJWK(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - KeyObjectHandle* key; - ASSIGN_OR_RETURN_UNWRAP(&key, args.This()); - MarkPopErrorOnReturn mark_pop_error_on_return; - - // The argument must be a JavaScript object that we will inspect - // to get the JWK properties from. - CHECK(args[0]->IsObject()); - - // Step one, Secret key or not? - Local input = args[0].As(); - - Local kty; - if (!input->Get(env->context(), env->jwk_kty_string()).ToLocal(&kty) || - !kty->IsString()) { - return THROW_ERR_CRYPTO_INVALID_JWK(env); - } - - Utf8Value kty_string(env->isolate(), kty); - - if (kty_string == "oct") { - // Secret key - key->data_ = ImportJWKSecretKey(env, input); - if (!key->data_) { - // ImportJWKSecretKey is responsible for throwing an appropriate error - return; - } - } else { - key->data_ = ImportJWKAsymmetricKey(env, input, *kty_string, args, 1); - if (!key->data_) { - // ImportJWKAsymmetricKey is responsible for throwing an appropriate error - return; - } - } - - args.GetReturnValue().Set(key->data_.GetKeyType()); -} - -void KeyObjectHandle::InitECRaw(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - KeyObjectHandle* key; - ASSIGN_OR_RETURN_UNWRAP(&key, args.This()); - - CHECK(args[0]->IsString()); - Utf8Value name(env->isolate(), args[0]); - - MarkPopErrorOnReturn mark_pop_error_on_return; - - int id = ncrypto::Ec::GetCurveIdFromName(*name); - if (id == NID_undef) return THROW_ERR_CRYPTO_INVALID_CURVE(env); - - auto eckey = ECKeyPointer::NewByCurveName(id); - if (!eckey) - return args.GetReturnValue().Set(false); - - const auto group = eckey.getGroup(); - auto pub = ECDH::BufferToPoint(env, group, args[1]); - - if (!pub || !eckey || !eckey.setPublicKey(pub)) { - return args.GetReturnValue().Set(false); - } - - auto pkey = EVPKeyPointer::New(); - if (!pkey.assign(eckey)) { - args.GetReturnValue().Set(false); - } - - eckey.release(); // Release ownership of the key - - key->data_ = KeyObjectData::CreateAsymmetric(kKeyTypePublic, std::move(pkey)); - - args.GetReturnValue().Set(true); -} - -void KeyObjectHandle::InitEDRaw(const FunctionCallbackInfo& args) { - KeyObjectHandle* key; - ASSIGN_OR_RETURN_UNWRAP(&key, args.This()); - - CHECK(args[0]->IsString()); - Utf8Value name(args.GetIsolate(), args[0]); - - ArrayBufferOrViewContents key_data(args[1]); - KeyType type = FromV8Value(args[2]); - - MarkPopErrorOnReturn mark_pop_error_on_return; - - typedef EVPKeyPointer (*new_key_fn)( - int, const ncrypto::Buffer&); - new_key_fn fn = type == kKeyTypePrivate ? EVPKeyPointer::NewRawPrivate - : EVPKeyPointer::NewRawPublic; - - int id = GetNidFromName(*name); - - switch (id) { - case EVP_PKEY_X25519: - case EVP_PKEY_X448: - case EVP_PKEY_ED25519: - case EVP_PKEY_ED448: { - auto pkey = fn(id, - ncrypto::Buffer{ - .data = key_data.data(), - .len = key_data.size(), - }); - if (!pkey) { - return args.GetReturnValue().Set(false); - } - key->data_ = KeyObjectData::CreateAsymmetric(type, std::move(pkey)); - CHECK(key->data_); - break; - } - default: - return args.GetReturnValue().Set(false); - } - - args.GetReturnValue().Set(true); -} - -void KeyObjectHandle::InitPqcRaw(const FunctionCallbackInfo& args) { -#if OPENSSL_WITH_PQC - KeyObjectHandle* key; - ASSIGN_OR_RETURN_UNWRAP(&key, args.This()); - - CHECK(args[0]->IsString()); - Utf8Value name(args.GetIsolate(), args[0]); - - ArrayBufferOrViewContents key_data(args[1]); - KeyType type = FromV8Value(args[2]); - - MarkPopErrorOnReturn mark_pop_error_on_return; - - int id = GetNidFromName(*name); - - typedef EVPKeyPointer (*new_key_fn)( - int, const ncrypto::Buffer&); - new_key_fn fn; - - switch (id) { - case EVP_PKEY_ML_DSA_44: - case EVP_PKEY_ML_DSA_65: - case EVP_PKEY_ML_DSA_87: - case EVP_PKEY_ML_KEM_512: - case EVP_PKEY_ML_KEM_768: - case EVP_PKEY_ML_KEM_1024: - fn = type == kKeyTypePrivate ? EVPKeyPointer::NewRawSeed - : EVPKeyPointer::NewRawPublic; - break; - case EVP_PKEY_SLH_DSA_SHA2_128F: - case EVP_PKEY_SLH_DSA_SHA2_128S: - case EVP_PKEY_SLH_DSA_SHA2_192F: - case EVP_PKEY_SLH_DSA_SHA2_192S: - case EVP_PKEY_SLH_DSA_SHA2_256F: - case EVP_PKEY_SLH_DSA_SHA2_256S: - case EVP_PKEY_SLH_DSA_SHAKE_128F: - case EVP_PKEY_SLH_DSA_SHAKE_128S: - case EVP_PKEY_SLH_DSA_SHAKE_192F: - case EVP_PKEY_SLH_DSA_SHAKE_192S: - case EVP_PKEY_SLH_DSA_SHAKE_256F: - case EVP_PKEY_SLH_DSA_SHAKE_256S: - fn = type == kKeyTypePrivate ? EVPKeyPointer::NewRawPrivate - : EVPKeyPointer::NewRawPublic; - break; - default: - return args.GetReturnValue().Set(false); - } - - auto pkey = fn(id, - ncrypto::Buffer{ - .data = key_data.data(), - .len = key_data.size(), - }); - if (!pkey) { - return args.GetReturnValue().Set(false); - } - key->data_ = KeyObjectData::CreateAsymmetric(type, std::move(pkey)); - CHECK(key->data_); - - args.GetReturnValue().Set(true); -#else - Environment* env = Environment::GetCurrent(args); - THROW_ERR_INVALID_ARG_VALUE(env, "Unsupported key type"); -#endif -} - void KeyObjectHandle::Equals(const FunctionCallbackInfo& args) { KeyObjectHandle* self_handle; KeyObjectHandle* arg_handle; @@ -1471,59 +1600,6 @@ void KeyObjectHandle::ExportECPrivateRaw( .FromMaybe(Local())); } -void KeyObjectHandle::InitECPrivateRaw( - const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - KeyObjectHandle* key; - ASSIGN_OR_RETURN_UNWRAP(&key, args.This()); - - CHECK(args[0]->IsString()); - Utf8Value name(env->isolate(), args[0]); - - ArrayBufferOrViewContents key_data(args[1]); - - MarkPopErrorOnReturn mark_pop_error_on_return; - - int nid = ncrypto::Ec::GetCurveIdFromName(*name); - if (nid == NID_undef) return THROW_ERR_CRYPTO_INVALID_CURVE(env); - - auto eckey = ECKeyPointer::NewByCurveName(nid); - if (!eckey) return args.GetReturnValue().Set(false); - - // Validate key data size matches the curve's expected private key length - const auto group = eckey.getGroup(); - auto order = BignumPointer::New(); - CHECK(order); - CHECK(EC_GROUP_get_order(group, order.get(), nullptr)); - if (key_data.size() != order.byteLength()) - return args.GetReturnValue().Set(false); - - BignumPointer priv_bn(key_data.data(), key_data.size()); - if (!priv_bn) return args.GetReturnValue().Set(false); - - if (!eckey.setPrivateKey(priv_bn)) return args.GetReturnValue().Set(false); - - // Compute public key from private key - auto pub_point = ECPointPointer::New(group); - if (!pub_point || !pub_point.mul(group, priv_bn.get())) { - return args.GetReturnValue().Set(false); - } - - if (!eckey.setPublicKey(pub_point)) return args.GetReturnValue().Set(false); - - auto pkey = EVPKeyPointer::New(); - if (!pkey.assign(eckey)) { - return args.GetReturnValue().Set(false); - } - - eckey.release(); - - key->data_ = - KeyObjectData::CreateAsymmetric(kKeyTypePrivate, std::move(pkey)); - - args.GetReturnValue().Set(true); -} - void KeyObjectHandle::RawSeed(const v8::FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); KeyObjectHandle* key; diff --git a/src/crypto/crypto_keys.h b/src/crypto/crypto_keys.h index 4a8438b38c9f1e..237a45ec955beb 100644 --- a/src/crypto/crypto_keys.h +++ b/src/crypto/crypto_keys.h @@ -150,9 +150,6 @@ class KeyObjectHandle : public BaseObject { static void New(const v8::FunctionCallbackInfo& args); static void Init(const v8::FunctionCallbackInfo& args); - static void InitECRaw(const v8::FunctionCallbackInfo& args); - static void InitEDRaw(const v8::FunctionCallbackInfo& args); - static void InitJWK(const v8::FunctionCallbackInfo& args); static void GetKeyDetail(const v8::FunctionCallbackInfo& args); static void Equals(const v8::FunctionCallbackInfo& args); @@ -176,8 +173,6 @@ class KeyObjectHandle : public BaseObject { const v8::FunctionCallbackInfo& args); static void ExportECPrivateRaw( const v8::FunctionCallbackInfo& args); - static void InitECPrivateRaw(const v8::FunctionCallbackInfo& args); - static void InitPqcRaw(const v8::FunctionCallbackInfo& args); static void RawSeed(const v8::FunctionCallbackInfo& args); v8::MaybeLocal ExportSecretKey() const; diff --git a/src/crypto/crypto_ml_dsa.cc b/src/crypto/crypto_ml_dsa.cc index 65f7053cc1fa1d..75cb17ab038091 100644 --- a/src/crypto/crypto_ml_dsa.cc +++ b/src/crypto/crypto_ml_dsa.cc @@ -7,6 +7,7 @@ namespace node { using ncrypto::DataPointer; +using ncrypto::EVPKeyPointer; using v8::Local; using v8::Object; using v8::String; @@ -80,6 +81,85 @@ bool ExportJwkMlDsaKey(Environment* env, .IsNothing() || !trySetKey(env, pkey.rawPublicKey(), target, env->jwk_pub_string())); } + +KeyObjectData ImportJWKAkpKey(Environment* env, Local jwk) { + Local alg_value; + Local pub_value; + Local priv_value; + + if (!jwk->Get(env->context(), env->jwk_alg_string()).ToLocal(&alg_value) || + !jwk->Get(env->context(), env->jwk_pub_string()).ToLocal(&pub_value) || + !jwk->Get(env->context(), env->jwk_priv_string()).ToLocal(&priv_value)) { + return {}; + } + + static constexpr int kMlDsaIds[] = { + EVP_PKEY_ML_DSA_44, EVP_PKEY_ML_DSA_65, EVP_PKEY_ML_DSA_87}; + + Utf8Value alg(env->isolate(), + alg_value->IsString() ? alg_value.As() + : String::Empty(env->isolate())); + + int id = NID_undef; + for (int candidate : kMlDsaIds) { + if (strcmp(*alg, GetMlDsaAlgorithmName(candidate)) == 0) { + id = candidate; + break; + } + } + + if (id == NID_undef) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Unsupported JWK AKP \"alg\""); + return {}; + } + + if (!pub_value->IsString() || + (!priv_value->IsUndefined() && !priv_value->IsString())) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK AKP key"); + return {}; + } + + KeyType type = priv_value->IsString() ? kKeyTypePrivate : kKeyTypePublic; + + EVPKeyPointer pkey; + if (type == kKeyTypePrivate) { + ByteSource seed = + ByteSource::FromEncodedString(env, priv_value.As()); + pkey = + EVPKeyPointer::NewRawSeed(id, + ncrypto::Buffer{ + .data = seed.data(), + .len = seed.size(), + }); + } else { + ByteSource pub = ByteSource::FromEncodedString(env, pub_value.As()); + pkey = + EVPKeyPointer::NewRawPublic(id, + ncrypto::Buffer{ + .data = pub.data(), + .len = pub.size(), + }); + } + + if (!pkey) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK AKP key"); + return {}; + } + + // When importing a private key, verify that the JWK's pub field matches + // the public key derived from the seed. + if (type == kKeyTypePrivate && pub_value->IsString()) { + ByteSource pub = ByteSource::FromEncodedString(env, pub_value.As()); + auto derived_pub = pkey.rawPublicKey(); + if (!derived_pub || derived_pub.size() != pub.size() || + CRYPTO_memcmp(derived_pub.get(), pub.data(), pub.size()) != 0) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK AKP key"); + return {}; + } + } + + return KeyObjectData::CreateAsymmetric(type, std::move(pkey)); +} #endif } // namespace crypto } // namespace node diff --git a/src/crypto/crypto_ml_dsa.h b/src/crypto/crypto_ml_dsa.h index e4739fcdd7fda7..8f8a395c5e94f5 100644 --- a/src/crypto/crypto_ml_dsa.h +++ b/src/crypto/crypto_ml_dsa.h @@ -13,6 +13,8 @@ namespace crypto { bool ExportJwkMlDsaKey(Environment* env, const KeyObjectData& key, v8::Local target); + +KeyObjectData ImportJWKAkpKey(Environment* env, v8::Local jwk); #endif } // namespace crypto } // namespace node diff --git a/src/crypto/crypto_rsa.cc b/src/crypto/crypto_rsa.cc index 3619c1d21dd238..e39a7fe72de651 100644 --- a/src/crypto/crypto_rsa.cc +++ b/src/crypto/crypto_rsa.cc @@ -318,10 +318,7 @@ bool ExportJWKRsaKey(Environment* env, return true; } -KeyObjectData ImportJWKRsaKey(Environment* env, - Local jwk, - const FunctionCallbackInfo& args, - unsigned int offset) { +KeyObjectData ImportJWKRsaKey(Environment* env, Local jwk) { Local n_value; Local e_value; Local d_value; @@ -395,6 +392,19 @@ KeyObjectData ImportJWKRsaKey(Environment* env, THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK RSA key"); return {}; } + + // Verify that n == p * q. + const auto& pub = rsa_view.getPublicKey(); + const auto& priv = rsa_view.getPrivateKey(); + auto pq = BignumPointer::New(); + BN_CTX* ctx = BN_CTX_new(); + bool n_valid = ctx && pq && BN_mul(pq.get(), priv.p, priv.q, ctx) == 1 && + BN_cmp(pq.get(), pub.n) == 0; + BN_CTX_free(ctx); + if (!n_valid) { + THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK RSA key"); + return {}; + } } auto pkey = EVPKeyPointer::NewRSA(std::move(rsa)); diff --git a/src/crypto/crypto_rsa.h b/src/crypto/crypto_rsa.h index 5b7f90f502df03..8ca657ea101fff 100644 --- a/src/crypto/crypto_rsa.h +++ b/src/crypto/crypto_rsa.h @@ -92,10 +92,7 @@ bool ExportJWKRsaKey(Environment* env, const KeyObjectData& key, v8::Local target); -KeyObjectData ImportJWKRsaKey(Environment* env, - v8::Local jwk, - const v8::FunctionCallbackInfo& args, - unsigned int offset); +KeyObjectData ImportJWKRsaKey(Environment* env, v8::Local jwk); bool GetRsaKeyDetail(Environment* env, const KeyObjectData& key, diff --git a/src/crypto/crypto_sig.cc b/src/crypto/crypto_sig.cc index 00108990b7fc70..bd3c9f538c5de5 100644 --- a/src/crypto/crypto_sig.cc +++ b/src/crypto/crypto_sig.cc @@ -615,7 +615,7 @@ Maybe SignTraits::AdditionalConfig( params->key = std::move(data); } - ArrayBufferOrViewContents data(args[offset + 5]); + ArrayBufferOrViewContents data(args[offset + 6]); if (!data.CheckSizeInt32()) [[unlikely]] { THROW_ERR_OUT_OF_RANGE(env, "data is too big"); return Nothing(); @@ -624,8 +624,8 @@ Maybe SignTraits::AdditionalConfig( ? data.ToCopy() : data.ToByteSource(); - if (args[offset + 6]->IsString()) { - Utf8Value digest(env->isolate(), args[offset + 6]); + if (args[offset + 7]->IsString()) { + Utf8Value digest(env->isolate(), args[offset + 7]); params->digest = Digest::FromName(*digest); if (!params->digest) [[unlikely]] { THROW_ERR_CRYPTO_INVALID_DIGEST(env, "Invalid digest: %s", digest); @@ -633,27 +633,27 @@ Maybe SignTraits::AdditionalConfig( } } - if (args[offset + 7]->IsInt32()) { // Salt length + if (args[offset + 8]->IsInt32()) { // Salt length params->flags |= SignConfiguration::kHasSaltLength; params->salt_length = - GetSaltLenFromJS(args[offset + 7]).value_or(params->salt_length); + GetSaltLenFromJS(args[offset + 8]).value_or(params->salt_length); } - if (args[offset + 8]->IsUint32()) { // Padding + if (args[offset + 9]->IsUint32()) { // Padding params->flags |= SignConfiguration::kHasPadding; params->padding = - GetPaddingFromJS(params->key.GetAsymmetricKey(), args[offset + 8]); + GetPaddingFromJS(params->key.GetAsymmetricKey(), args[offset + 9]); } - if (args[offset + 9]->IsUint32()) { // DSA Encoding - params->dsa_encoding = GetDSASigEncFromJS(args[offset + 9]); + if (args[offset + 10]->IsUint32()) { // DSA Encoding + params->dsa_encoding = GetDSASigEncFromJS(args[offset + 10]); if (params->dsa_encoding == DSASigEnc::Invalid) [[unlikely]] { THROW_ERR_OUT_OF_RANGE(env, "invalid signature encoding"); return Nothing(); } } - if (!args[offset + 10]->IsUndefined()) { // Context string - ArrayBufferOrViewContents context_string(args[offset + 10]); + if (!args[offset + 11]->IsUndefined()) { // Context string + ArrayBufferOrViewContents context_string(args[offset + 11]); if (context_string.size() > 255) [[unlikely]] { THROW_ERR_OUT_OF_RANGE(env, "context string must be at most 255 bytes"); return Nothing(); @@ -665,7 +665,7 @@ Maybe SignTraits::AdditionalConfig( } if (params->mode == SignConfiguration::Mode::Verify) { - ArrayBufferOrViewContents signature(args[offset + 11]); + ArrayBufferOrViewContents signature(args[offset + 12]); if (!signature.CheckSizeInt32()) [[unlikely]] { THROW_ERR_OUT_OF_RANGE(env, "signature is too big"); return Nothing(); diff --git a/test/parallel/test-crypto-key-objects.js b/test/parallel/test-crypto-key-objects.js index 6c1c3fd3afa448..52fcad0882e1c7 100644 --- a/test/parallel/test-crypto-key-objects.js +++ b/test/parallel/test-crypto-key-objects.js @@ -246,6 +246,18 @@ const privateDsa = fixtures.readKey('dsa_private_encrypted_1025.pem', code: 'ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS' }); + // Importing an RSA private JWK where n does not equal p * q should fail. + assert.throws( + () => createPrivateKey({ key: { ...jwk, n: `A${publicJwk.n.slice(1)}` }, format: 'jwk' }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + + // Importing a public-only RSA JWK as a private key should fail. + assert.throws( + () => createPrivateKey({ key: publicJwk, format: 'jwk' }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + const publicDER = publicKey.export({ format: 'der', type: 'pkcs1' @@ -461,6 +473,51 @@ const privateDsa = fixtures.readKey('dsa_private_encrypted_1025.pem', } }); +// Importing an OKP private JWK where x does not match d should fail. +{ + const okpJwk = { + crv: 'Ed25519', + x: 'K1wIouqnuiA04b3WrMa-xKIKIpfHetNZRv3h9fBf768', + d: 'wVK6M3SMhQh3NK-7GRrSV-BVWQx1FO5pW8hhQeu_NdA', + kty: 'OKP' + }; + + assert.throws( + () => createPrivateKey({ + key: { ...okpJwk, x: `A${okpJwk.x.slice(1)}` }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + + // Importing a public-only OKP JWK as a private key should fail. + assert.throws( + () => createPrivateKey({ + key: { kty: okpJwk.kty, crv: okpJwk.crv, x: okpJwk.x }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + + // Importing an OKP JWK with missing crv should fail. + assert.throws( + () => createPublicKey({ + key: { kty: okpJwk.kty, x: okpJwk.x }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + + // Importing an OKP JWK with invalid crv should fail. + assert.throws( + () => createPublicKey({ + key: { ...okpJwk, crv: 'invalid' }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); +} + [ { private: fixtures.readKey('ec_p256_private.pem', 'ascii'), public: fixtures.readKey('ec_p256_public.pem', 'ascii'), @@ -593,6 +650,52 @@ const privateDsa = fixtures.readKey('dsa_private_encrypted_1025.pem', } }); +// Importing an EC private JWK where x does not match d should fail. +{ + const ecJwk = { + crv: 'P-256', + d: 'DxBsPQPIgMuMyQbxzbb9toew6Ev6e9O6ZhpxLNgmAEo', + kty: 'EC', + x: 'X0mMYR_uleZSIPjNztIkAS3_ud5LhNpbiIFp6fNf2Gs', + y: 'UbJuPy2Xi0lW7UYTBxPK3yGgDu9EAKYIecjkHX5s2lI' + }; + + assert.throws( + () => createPrivateKey({ + key: { ...ecJwk, x: `A${ecJwk.x.slice(1)}` }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + + // Importing a public-only EC JWK as a private key should fail. + assert.throws( + () => createPrivateKey({ + key: { kty: ecJwk.kty, crv: ecJwk.crv, x: ecJwk.x, y: ecJwk.y }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + + // Importing an EC JWK with missing crv should fail. + assert.throws( + () => createPublicKey({ + key: { kty: ecJwk.kty, x: ecJwk.x, y: ecJwk.y }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + + // Importing an EC JWK with invalid crv should fail. + assert.throws( + () => createPublicKey({ + key: { ...ecJwk, crv: 'invalid' }, + format: 'jwk', + }), + { code: 'ERR_CRYPTO_INVALID_CURVE' } + ); +} + { // Reading an encrypted key without a passphrase should fail. assert.throws(() => createPrivateKey(privateDsa), hasOpenSSL3 ? { diff --git a/test/parallel/test-crypto-pqc-key-objects-ml-dsa.js b/test/parallel/test-crypto-pqc-key-objects-ml-dsa.js index 1a832609d3f813..ddce614a56ea9b 100644 --- a/test/parallel/test-crypto-pqc-key-objects-ml-dsa.js +++ b/test/parallel/test-crypto-pqc-key-objects-ml-dsa.js @@ -143,18 +143,28 @@ for (const [asymmetricKeyType, pubLen] of [ if (hasOpenSSL(3, 5)) { assert.throws(() => createPrivateKey({ format, key: { ...jwk, alg: 'ml-dsa-44' } }), - { code: 'ERR_INVALID_ARG_VALUE', message: /must be one of: 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'/ }); + { code: 'ERR_CRYPTO_INVALID_JWK' }); assert.throws(() => createPrivateKey({ format, key: { ...jwk, alg: undefined } }), - { code: 'ERR_INVALID_ARG_VALUE', message: /must be one of: 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'/ }); + { code: 'ERR_CRYPTO_INVALID_JWK' }); assert.throws(() => createPrivateKey({ format, key: { ...jwk, pub: undefined } }), - { code: 'ERR_INVALID_ARG_TYPE', message: /The "key\.pub" property must be of type string/ }); + { code: 'ERR_CRYPTO_INVALID_JWK' }); assert.throws(() => createPrivateKey({ format, key: { ...jwk, priv: undefined } }), - { code: 'ERR_INVALID_ARG_TYPE', message: /The "key\.priv" property must be of type string/ }); + { code: 'ERR_CRYPTO_INVALID_JWK', message: /JWK does not contain private key material/ }); assert.throws(() => createPrivateKey({ format, key: { ...jwk, priv: Buffer.alloc(33).toString('base64url') } }), { code: 'ERR_CRYPTO_INVALID_JWK' }); - assert.throws(() => createPublicKey({ format, key: { ...jwk, pub: Buffer.alloc(1313).toString('base64url') } }), + // eslint-disable-next-line @stylistic/js/max-len + assert.throws(() => createPublicKey({ format, key: { kty: jwk.kty, alg: jwk.alg, pub: Buffer.alloc(1313).toString('base64url') } }), { code: 'ERR_CRYPTO_INVALID_JWK' }); + // Importing an ML-DSA private JWK where pub does not match priv should fail. + assert.throws( + () => createPrivateKey({ + format, + key: { ...jwk, pub: `${jwk.pub[0] === 'A' ? 'B' : 'A'}${jwk.pub.slice(1)}` }, + }), + { code: 'ERR_CRYPTO_INVALID_JWK' } + ); + assert.ok(createPrivateKey({ format, key: jwk })); assert.ok(createPublicKey({ format, key: jwk })); diff --git a/test/parallel/test-webcrypto-export-import-ml-dsa.js b/test/parallel/test-webcrypto-export-import-ml-dsa.js index ebd61c67f55d32..f522b2cd5066e7 100644 --- a/test/parallel/test-webcrypto-export-import-ml-dsa.js +++ b/test/parallel/test-webcrypto-export-import-ml-dsa.js @@ -320,7 +320,7 @@ async function testImportJwk({ name, publicUsages, privateUsages }, extractable) { name }, extractable, privateUsages), - { message: 'Invalid JWK' }); + { message: 'Invalid keyData' }); await assert.rejects( subtle.importKey( From f8ee196c15784c372f30bfbb7d70956fc4d7eb63 Mon Sep 17 00:00:00 2001 From: Jinho Jang Date: Wed, 1 Apr 2026 05:51:44 +0900 Subject: [PATCH 7/7] fs: expose frsize field in statfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose `f_frsize` from libuv's `uv_statfs_t` as `statfs.frsize`. Per POSIX, `f_blocks`, `f_bfree`, and `f_bavail` are expressed in units of `f_frsize`, not `f_bsize`. On most filesystems the two values are typically equal, but some filesystem drivers report a different `f_bsize`, making it impossible to compute accurate disk usage without `frsize`. Refs: https://github.com/libuv/libuv/issues/4983 PR-URL: https://github.com/nodejs/node/pull/62277 Reviewed-By: Anna Henningsen Reviewed-By: Juan José Arboleda Reviewed-By: Luigi Pinca Reviewed-By: Gürgün Dayıoğlu --- doc/api/fs.md | 12 ++++++++++++ lib/internal/fs/utils.js | 5 +++-- src/node_file-inl.h | 1 + src/node_file.h | 1 + test/parallel/test-fs-promises.js | 1 + test/parallel/test-fs-statfs.js | 2 +- 6 files changed, 19 insertions(+), 3 deletions(-) diff --git a/doc/api/fs.md b/doc/api/fs.md index 6330a921732f46..c793dc67830f7c 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -7895,6 +7895,7 @@ numeric values will be `bigint` instead of `number`. StatFs { type: 1397114950, bsize: 4096, + frsize: 4096, blocks: 121938943, bfree: 61058895, bavail: 61058895, @@ -7909,6 +7910,7 @@ StatFs { StatFs { type: 1397114950n, bsize: 4096n, + frsize: 4096n, blocks: 121938943n, bfree: 61058895n, bavail: 61058895n, @@ -7965,6 +7967,16 @@ added: Optimal transfer block size. +#### `statfs.frsize` + + + +* Type: {number|bigint} + +Fundamental file system block size. + #### `statfs.ffree`