diff --git a/bench/snippets/url-kinds.mjs b/bench/snippets/url-kinds.mjs new file mode 100644 index 00000000000..5b59393933c --- /dev/null +++ b/bench/snippets/url-kinds.mjs @@ -0,0 +1,100 @@ +// new URL() / URL.canParse() / URL.parse() across the URL shapes that show up in real code. +import { bench, group, run } from "../runner.mjs"; + +const kinds = { + "origin only": "https://example.com", + "origin + slash": "https://example.com/", + "short path": "https://bun.sh/docs/api/http", + "path + query + hash": "https://example.com/search?q=bun+url+parser&page=2#results", + "long CDN path": + "https://cdn.example.com/assets/v3/2024/08/16/9f8e7d6c5b4a/bundle.min.js?integrity=sha384-oqVuAfXRKap7fdgc", + "GitHub API": "https://api.github.com/repos/oven-sh/bun/pulls?state=open&per_page=100&sort=updated", + "localhost + port": "http://localhost:3000/api/users/42", + "credentials + port": "https://user:p%40ss@registry.internal:8443/npm/@scope%2fpkg", + "IPv4 host": "http://192.168.1.10:8080/metrics", + "IPv6 host": "http://[2001:db8::8a2e:370:7334]:8080/status", + "IDN host": "https://日本語.jp/ニュース?ページ=1", + "percent-encoded": "https://example.com/a%20b/%E6%97%A5%E6%9C%AC?x=%3D%26", + "needs normalizing": "HTTPS://WWW.Example.COM:443/a/./b/../c/%7efoo", + "file URL": "file:///home/user/projects/bun/src/main.rs", + "Windows file URL": "file:///C:/Users/me/AppData/Local/Temp/file.txt", + "non-special scheme": "git+ssh://git@github.com/oven-sh/bun.git", + "data: URL": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==", + "very long query (2 KB)": + "https://example.com/track?" + Array.from({ length: 60 }, (_, i) => `utm_${i}=value_${i}_abcdefghij`).join("&"), +}; + +const relative = [ + ["relative path", "../assets/logo.svg", "https://example.com/docs/guide/intro.html"], + ["absolute path", "/api/v1/items?limit=10", "https://example.com/app/index.html"], + ["query only", "?page=3", "https://example.com/list?page=2#top"], + ["scheme-relative", "//cdn.example.com/lib.js", "https://example.com/"], +]; + +const invalid = ["not a url", "http://", "https://exa mple.com/", "http://[::1/", "https://xn--a.com/"]; + +group("new URL(absolute)", () => { + for (const [name, input] of Object.entries(kinds)) bench(name, () => new URL(input)); +}); + +group("new URL(relative, base)", () => { + for (const [name, input, base] of relative) bench(name, () => new URL(input, base)); + const baseURL = new URL("https://example.com/docs/guide/intro.html"); + bench("relative path (URL base)", () => new URL("../assets/logo.svg", baseURL)); +}); + +group("URL.canParse", () => { + for (const name of [ + "origin + slash", + "path + query + hash", + "long CDN path", + "IPv6 host", + "IDN host", + "needs normalizing", + ]) + bench(name, () => URL.canParse(kinds[name])); + bench("invalid (5 kinds)", () => { + let n = 0; + for (const input of invalid) n += URL.canParse(input); + return n; + }); +}); + +group("literal punycode host", () => { + bench("canParse valid (xn--ls8h.com)", () => URL.canParse("https://xn--ls8h.com/p")); + bench("canParse invalid (xn--a.com)", () => URL.canParse("https://xn--a.com/p")); +}); + +group("same string base every call", () => { + const base = "https://example.com/app/index.html"; + bench("new URL(path, base)", () => new URL("/api/v1/items?limit=10", base)); + bench("URL.parse(path, base)", () => URL.parse("/api/v1/items?limit=10", base)); + bench("URL.canParse(path, base)", () => URL.canParse("/api/v1/items?limit=10", base)); +}); + +group("URL.parse", () => { + bench("path + query + hash", () => URL.parse(kinds["path + query + hash"])); + bench("invalid", () => URL.parse("https://exa mple.com/")); +}); + +group("new URL() + accessors", () => { + bench("href", () => new URL(kinds["path + query + hash"]).href); + bench("pathname + search + hash", () => { + const u = new URL(kinds["path + query + hash"]); + return u.pathname.length + u.search.length + u.hash.length; + }); + bench("searchParams.get", () => new URL(kinds["path + query + hash"]).searchParams.get("q")); + bench("toString()", () => new URL(kinds["GitHub API"]).toString()); +}); + +group("new URL(invalid) throws", () => { + bench("try/catch", () => { + try { + return new URL("https://exa mple.com/"); + } catch { + return null; + } + }); +}); + +await run(); diff --git a/src/jsc/bindings/ASCIIHostPunycodeCheck.h b/src/jsc/bindings/ASCIIHostPunycodeCheck.h new file mode 100644 index 00000000000..63677609048 --- /dev/null +++ b/src/jsc/bindings/ASCIIHostPunycodeCheck.h @@ -0,0 +1,222 @@ +#pragma once + +// Decides, without running full UTS #46 ToASCII, whether an all-ASCII host whose only possible problem is its +// "xn--" labels would pass ICU's uidna_nameToASCII(CHECK_BIDI | CHECK_CONTEXTJ | NONTRANSITIONAL_TO_ASCII) with the +// hyphen and length errors ignored. Mirrors icu::UTS46::processLabel for ACE labels: the label must Punycode-decode +// (u_strFromPunycode's rules), the decoding must be unchanged by the uts46 normalizer (valid, NFC), must not contain +// U+FFFD or start with a combining mark. Labels that would then need the BiDi or CONTEXTJ rules are left to ICU. +// Deliberately free of WTF so it can be tested standalone against ICU. + +#include +#include +#include +#if __has_include() +#include +#else +// The macOS SDK ships only a subset of the ICU headers; these two entry points are stable C API in libicucore. +struct UNormalizer2; +typedef enum { UNORM2_COMPOSE, + UNORM2_DECOMPOSE, + UNORM2_FCD, + UNORM2_COMPOSE_CONTIGUOUS } UNormalization2Mode; +extern "C" const UNormalizer2* unorm2_getInstance(const char* packageName, const char* name, UNormalization2Mode, UErrorCode*); +extern "C" UBool unorm2_isNormalized(const UNormalizer2*, const UChar*, int32_t length, UErrorCode*); +#endif + +namespace Bun { + +enum class ASCIIHostPunycodeVerdict : uint8_t { Valid, + Invalid, + NeedsFullCheck }; + +namespace PunycodeDetail { + +static constexpr int32_t base = 36; +static constexpr int32_t tMin = 1; +static constexpr int32_t tMax = 26; +static constexpr int32_t skew = 38; +static constexpr int32_t damp = 700; +static constexpr int32_t initialBias = 72; +static constexpr int32_t initialN = 0x80; +static constexpr size_t maxCodePoints = 256; // A DNS label is at most 63 bytes; anything this long is left to ICU. + +inline int32_t adaptBias(int32_t delta, int32_t length, bool firstTime) +{ + delta = firstTime ? delta / damp : delta / 2; + delta += delta / length; + int32_t count = 0; + for (; delta > ((base - tMin) * tMax) / 2; count += base) + delta /= (base - tMin); + return count + (((base - tMin + 1) * delta) / (delta + skew)); +} + +inline int32_t digitValue(unsigned c) +{ + if (c - 'a' < 26) + return c - 'a'; + if (c - '0' < 10) + return c - '0' + 26; + if (c - 'A' < 26) + return c - 'A'; + return -1; +} + +// RFC 3492 decoding with u_strFromPunycode's failure conditions. Returns the number of code points, -1 if the input is +// not valid Punycode, or -2 if it is merely too long to judge here. +template +inline int32_t decode(const CharacterType* source, size_t sourceLength, char32_t (&destination)[maxCodePoints]) +{ + if (sourceLength > maxCodePoints) + return -2; + // Everything before the last '-' (if any) is literal; a '-' at index 0 leaves nothing literal and is then + // itself read as a digit, which fails, as in u_strFromPunycode. + size_t basicLength = sourceLength; + while (basicLength > 0) { + if (source[--basicLength] == '-') + break; + } + int32_t destLength = 0; + for (size_t j = 0; j < basicLength; ++j) { + unsigned c = source[j]; + if (c >= 0x80) + return -1; + destination[destLength++] = c - 'A' < 26 ? c | 0x20 : c; // ICU lowercases ASCII before it gets here. + } + int32_t n = initialN; + int32_t i = 0; + int32_t bias = initialBias; + int32_t destCPCount = basicLength; + for (size_t in = basicLength > 0 ? basicLength + 1 : 0; in < sourceLength;) { + int32_t oldi = i; + int32_t w = 1; + for (int32_t k = base;; k += base) { + if (in >= sourceLength) + return -1; + int32_t digit = digitValue(source[in++]); + if (digit < 0) + return -1; + if (digit > (0x7fffffff - i) / w) + return -1; + i += digit * w; + int32_t t = k - bias; + if (t < tMin) + t = tMin; + else if (k >= bias + tMax) + t = tMax; + if (digit < t) + break; + if (w > 0x7fffffff / (base - t)) + return -1; + w *= base - t; + } + ++destCPCount; + bias = adaptBias(i - oldi, destCPCount, oldi == 0); + if (i / destCPCount > 0x7fffffff - n) + return -1; + n += i / destCPCount; + i %= destCPCount; + if (n > 0x10ffff || (n & 0xfffff800) == 0xd800) + return -1; + if (static_cast(destLength) >= maxCodePoints) + return -2; + for (int32_t move = destLength; move > i; --move) + destination[move] = destination[move - 1]; + destination[i] = n; + ++destLength; + ++i; + } + return destLength; +} + +inline const UNormalizer2* uts46Normalizer() +{ + static const UNormalizer2* instance = [] { + UErrorCode status = U_ZERO_ERROR; + const UNormalizer2* normalizer = unorm2_getInstance(nullptr, "uts46", UNORM2_COMPOSE, &status); + return U_SUCCESS(status) ? normalizer : nullptr; + }(); + return instance; +} + +template +inline ASCIIHostPunycodeVerdict checkLabel(const CharacterType* label, size_t length) +{ + if (length < 4 || (label[0] | 0x20) != 'x' || (label[1] | 0x20) != 'n' || label[2] != '-' || label[3] != '-') + return ASCIIHostPunycodeVerdict::Valid; // Nothing about an ASCII non-ACE label is an error we report. + // "xn--" alone and "xn--ascii-" are alternate encodings of ASCII labels. + if (length == 4 || (length > 5 && label[length - 1] == '-')) + return ASCIIHostPunycodeVerdict::Invalid; + char32_t codePoints[maxCodePoints]; + int32_t count = decode(label + 4, length - 4, codePoints); + if (count < 0) + return count == -2 ? ASCIIHostPunycodeVerdict::NeedsFullCheck : ASCIIHostPunycodeVerdict::Invalid; + if (!count) + return ASCIIHostPunycodeVerdict::NeedsFullCheck; + + UChar utf16[maxCodePoints * 2]; + int32_t utf16Length = 0; + bool needsContextRules = false; + for (int32_t k = 0; k < count; ++k) { + char32_t c = codePoints[k]; + if (c == 0xfffd) + return ASCIIHostPunycodeVerdict::Invalid; + if (c >= 0x80) { + // ZWNJ/ZWJ need the CONTEXTJ rules and right-to-left characters the BiDi rule; let ICU judge those. + if (c == 0x200c || c == 0x200d) + needsContextRules = true; + else { + switch (u_charDirection(c)) { + case U_RIGHT_TO_LEFT: + case U_RIGHT_TO_LEFT_ARABIC: + case U_ARABIC_NUMBER: + needsContextRules = true; + break; + default: + break; + } + } + } + if (c < 0x10000) + utf16[utf16Length++] = static_cast(c); + else { + utf16[utf16Length++] = static_cast((c >> 10) + 0xd7c0); + utf16[utf16Length++] = static_cast((c & 0x3ff) | 0xdc00); + } + } + const UNormalizer2* normalizer = uts46Normalizer(); + if (!normalizer) + return ASCIIHostPunycodeVerdict::NeedsFullCheck; + UErrorCode status = U_ZERO_ERROR; + if (!unorm2_isNormalized(normalizer, utf16, utf16Length, &status) || U_FAILURE(status)) + return U_FAILURE(status) ? ASCIIHostPunycodeVerdict::NeedsFullCheck : ASCIIHostPunycodeVerdict::Invalid; + if (U_GET_GC_MASK(codePoints[0]) & U_GC_M_MASK) + return ASCIIHostPunycodeVerdict::Invalid; + return needsContextRules ? ASCIIHostPunycodeVerdict::NeedsFullCheck : ASCIIHostPunycodeVerdict::Valid; +} + +} // namespace PunycodeDetail + +// `host` must be ASCII. Labels are separated by '.'. +template +inline ASCIIHostPunycodeVerdict checkASCIIHostPunycode(const CharacterType* host, size_t length) +{ + auto verdict = ASCIIHostPunycodeVerdict::Valid; + size_t labelStart = 0; + for (size_t i = 0; i <= length; ++i) { + if (i != length && host[i] != '.') + continue; + switch (PunycodeDetail::checkLabel(host + labelStart, i - labelStart)) { + case ASCIIHostPunycodeVerdict::Invalid: + return ASCIIHostPunycodeVerdict::Invalid; + case ASCIIHostPunycodeVerdict::NeedsFullCheck: + verdict = ASCIIHostPunycodeVerdict::NeedsFullCheck; + break; + case ASCIIHostPunycodeVerdict::Valid: + break; + } + labelStart = i + 1; + } + return verdict; +} + +} // namespace Bun diff --git a/src/jsc/bindings/BunClientData.h b/src/jsc/bindings/BunClientData.h index eed489a6a7d..0140bf847c3 100644 --- a/src/jsc/bindings/BunClientData.h +++ b/src/jsc/bindings/BunClientData.h @@ -66,6 +66,7 @@ class DOMWrapperWorld; #include #include "JSCTaskScheduler.h" #include "HTTPHeaderIdentifiers.h" +#include "DOMURLBaseCache.h" namespace Zig { class GlobalObject; } @@ -170,6 +171,8 @@ class JSVMClientData : public JSC::VM::ClientData { // so there is no startup cost worth deferring. WebCore::HTTPHeaderIdentifiers& httpHeaderIdentifiers() { return m_httpHeaderIdentifiers; } + WebCore::DOMURLBaseCache& urlBaseCache() { return m_urlBaseCache; } + void* bunVM; // Opaque box of the Rust VmHandle for this VM: what any *other* thread uses // to post work / ref the loop (never bunVM). Created in create(), released @@ -234,6 +237,8 @@ class JSVMClientData : public JSC::VM::ClientData { WebCore::HTTPHeaderIdentifiers m_httpHeaderIdentifiers; + WebCore::DOMURLBaseCache m_urlBaseCache; + SentinelLinkedList> m_clients; bool m_isWorkerVM { false }; diff --git a/src/jsc/bindings/DOMURL.cpp b/src/jsc/bindings/DOMURL.cpp index f853f796fbd..23668383be8 100644 --- a/src/jsc/bindings/DOMURL.cpp +++ b/src/jsc/bindings/DOMURL.cpp @@ -28,16 +28,28 @@ #include "NodeURLHelpers.h" #include "URLSearchParams.h" +#include namespace WebCore { // The WHATWG parser (WebKit) fast-paths all-ASCII hosts without validating // xn-- labels; Node's ada rejects invalid punycode in special-scheme hosts. // `input` is the string the host was parsed from (a base URL's host was checked when the base was parsed). +template +static bool containsXNDashDash(std::span host) +{ + // "--" is rare in hosts; look for it and check the two characters before it. Special-scheme hosts are lowercase here. + for (size_t i = WTF::find(host, '-'); i != notFound && i + 1 < host.size(); i = WTF::find(host, '-', i + 1)) { + if (host[i + 1] == '-' && i >= 2 && host[i - 2] == 'x' && host[i - 1] == 'n') + return true; + } + return false; +} + static bool hasValidParsedHost(const URL& url, const String& input) { auto host = url.host(); - if (host.length() < 4 || !host.contains("xn--"_s)) + if (host.length() < 4 || !(host.is8Bit() ? containsXNDashDash(host.span8()) : containsXNDashDash(host.span16()))) return true; // Non-special schemes have opaque hosts and skip IDNA entirely. if (!url.hasSpecialScheme()) @@ -96,20 +108,35 @@ ExceptionOr> DOMURL::create(const String& url, const URL& base, cons return adoptRef(*new DOMURL(WTF::move(completeURL))); } -ExceptionOr> DOMURL::create(const String& url, const String& base) +// A null URL means the base did not parse or has an invalid host. +static URL parseBase(const String& base, DOMURL::BaseURLCache* cache) { + if (cache && cache->input == base) [[likely]] + return cache->url; URL baseURL { base }; - if (!base.isNull() && (!baseURL.isValid() || !hasValidParsedHost(baseURL, base))) + if (!baseURL.isValid() || !hasValidParsedHost(baseURL, base)) + return {}; + if (cache) { + cache->input = base; + cache->url = baseURL; + } + return baseURL; +} + +ExceptionOr> DOMURL::create(const String& url, const String& base, BaseURLCache* cache) +{ + URL baseURL = base.isNull() ? URL {} : parseBase(base, cache); + if (!base.isNull() && !baseURL.isValid()) return Exception { InvalidURLError, url, base }; return create(url, baseURL, base); } DOMURL::~DOMURL() = default; -static URL parseInternal(const String& url, const String& base) +static URL parseInternal(const String& url, const String& base, DOMURL::BaseURLCache* cache) { - URL baseURL { base }; - if (!base.isNull() && (!baseURL.isValid() || !hasValidParsedHost(baseURL, base))) + URL baseURL = base.isNull() ? URL {} : parseBase(base, cache); + if (!base.isNull() && !baseURL.isValid()) return {}; URL result { baseURL, url }; if (result.isValid() && !hasValidParsedHost(result, url)) @@ -117,17 +144,17 @@ static URL parseInternal(const String& url, const String& base) return result; } -RefPtr DOMURL::parse(const String& url, const String& base) +RefPtr DOMURL::parse(const String& url, const String& base, BaseURLCache* cache) { - auto completeURL = parseInternal(url, base); + auto completeURL = parseInternal(url, base, cache); if (!completeURL.isValid()) return {}; return adoptRef(*new DOMURL(WTF::move(completeURL))); } -bool DOMURL::canParse(const String& url, const String& base) +bool DOMURL::canParse(const String& url, const String& base, BaseURLCache* cache) { - return parseInternal(url, base).isValid(); + return parseInternal(url, base, cache).isValid(); } ExceptionOr DOMURL::setHref(const String& url) @@ -145,7 +172,7 @@ ExceptionOr DOMURL::setHref(const String& url) // The update steps invoked on URLSearchParams::{append,set,delete,sort} set // m_searchParamsDirty instead of eagerly re-serializing m_url on every call so // that N appends through url.searchParams stay O(N) instead of O(N^2). All -// reads of m_url (href/toJSON/fullURL) call this first to reconcile. +// reads of m_url (href/fullURL) call this first to reconcile. void DOMURL::flushPendingSearchParamsUpdate() const { if (!m_searchParamsDirty) [[likely]] diff --git a/src/jsc/bindings/DOMURL.h b/src/jsc/bindings/DOMURL.h index 7389870debb..6928f44f305 100644 --- a/src/jsc/bindings/DOMURL.h +++ b/src/jsc/bindings/DOMURL.h @@ -31,6 +31,7 @@ #include "ExceptionOr.h" #include "URLDecomposition.h" #include +#include "DOMURLBaseCache.h" #include namespace WebCore { @@ -39,12 +40,14 @@ class URLSearchParams; class DOMURL final : public RefCounted, public CanMakeWeakPtr, public URLDecomposition { public: - static ExceptionOr> create(const String& url, const String& base); + using BaseURLCache = DOMURLBaseCache; + + static ExceptionOr> create(const String& url, const String& base, BaseURLCache* = nullptr); static ExceptionOr> create(const String& url); WEBCORE_EXPORT ~DOMURL(); - static RefPtr parse(const String& url, const String& base); - static bool canParse(const String& url, const String& base); + static RefPtr parse(const String& url, const String& base, BaseURLCache* = nullptr); + static bool canParse(const String& url, const String& base, BaseURLCache* = nullptr); const URL& href() const { @@ -56,12 +59,6 @@ class DOMURL final : public RefCounted, public CanMakeWeakPtr, p URLSearchParams& searchParams(); void markSearchParamsDirty() { m_searchParamsDirty = true; } - const String& toJSON() const - { - flushPendingSearchParamsUpdate(); - return m_url.string(); - } - size_t memoryCost() const { return sizeof(DOMURL) + m_url.string().sizeInBytes(); diff --git a/src/jsc/bindings/DOMURLBaseCache.h b/src/jsc/bindings/DOMURLBaseCache.h new file mode 100644 index 00000000000..ebdae0ed905 --- /dev/null +++ b/src/jsc/bindings/DOMURLBaseCache.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace WebCore { + +// new URL(input, base) is very often called with the same base string over and over (a configured origin, the +// current request's URL); the last base that parsed and validated is kept here, one per VM (JSVMClientData). +struct DOMURLBaseCache { + WTF::String input; + WTF::URL url; +}; + +} // namespace WebCore diff --git a/src/jsc/bindings/NodeURL.cpp b/src/jsc/bindings/NodeURL.cpp index 59331350446..5a84cb0134c 100644 --- a/src/jsc/bindings/NodeURL.cpp +++ b/src/jsc/bindings/NodeURL.cpp @@ -1,4 +1,5 @@ #include "NodeURL.h" +#include "ASCIIHostPunycodeCheck.h" #include "ErrorCode.h" #include "wtf/URL.h" #include "wtf/URLParser.h" @@ -106,6 +107,11 @@ bool hasValidPunycodeHost(WTF::StringView host) { if (!host.contains("xn--"_s)) return true; + if (host.containsOnlyASCII()) { + auto verdict = host.is8Bit() ? checkASCIIHostPunycode(host.span8().data(), host.length()) : checkASCIIHostPunycode(host.span16().data(), host.length()); + if (verdict != ASCIIHostPunycodeVerdict::NeedsFullCheck) + return verdict == ASCIIHostPunycodeVerdict::Valid; + } return !icuToASCII(host.toString(), IDNAMode::Default).isNull(); } diff --git a/src/jsc/bindings/webcore/JSDOMURL.cpp b/src/jsc/bindings/webcore/JSDOMURL.cpp index 0d045c4e7db..b436cb5c429 100644 --- a/src/jsc/bindings/webcore/JSDOMURL.cpp +++ b/src/jsc/bindings/webcore/JSDOMURL.cpp @@ -20,6 +20,7 @@ #include "config.h" #include "JSDOMURL.h" +#include "BunClientData.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -166,7 +167,7 @@ template<> EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSDOMURLDOMConstructor::const auto base = argument1.value().isUndefined() ? String() : convert(*lexicalGlobalObject, argument1.value()); RETURN_IF_EXCEPTION(throwScope, {}); // An empty base string must still be parsed (and fail) per the URL spec. - auto object = base.isNull() ? DOMURL::create(WTF::move(url)) : DOMURL::create(WTF::move(url), WTF::move(base)); + auto object = base.isNull() ? DOMURL::create(WTF::move(url)) : DOMURL::create(WTF::move(url), WTF::move(base), &WebCore::clientData(vm)->urlBaseCache()); if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); static_assert(TypeOrExceptionOrUnderlyingType::isRef); @@ -177,6 +178,12 @@ template<> EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSDOMURLDOMConstructor::const RETURN_IF_EXCEPTION(throwScope, {}); auto* jsDOMURL = uncheckedDowncast(jsValue.asCell()); vm.heap.reportExtraMemoryAllocated(jsDOMURL, jsDOMURL->wrapped().memoryCostForGC()); + if (argument0.value().isString()) { + // An already-canonical URL parses to the argument's own StringImpl; hand that JSString back from href. + JSString* input = asString(argument0.value()); + if (input->tryGetValueImpl() == jsDOMURL->wrapped().href().string().impl()) + jsDOMURL->m_href.set(vm, jsDOMURL, input); + } return JSValue::encode(jsValue); } JSC_ANNOTATE_HOST_FUNCTION(JSDOMURLDOMConstructorConstruct, JSDOMURLDOMConstructor::construct); @@ -271,12 +278,20 @@ JSC_DEFINE_CUSTOM_GETTER(jsDOMURLConstructor, (JSGlobalObject * lexicalGlobalObj return JSValue::encode(JSDOMURL::getConstructor(vm, prototype->globalObject())); } -static inline JSValue jsDOMURL_hrefGetter(JSGlobalObject& lexicalGlobalObject, JSDOMURL& thisObject) +JSString* JSDOMURL::href(JSGlobalObject& lexicalGlobalObject) const { auto& vm = JSC::getVM(&lexicalGlobalObject); - auto throwScope = DECLARE_THROW_SCOPE(vm); - auto& impl = thisObject.wrapped(); - RELEASE_AND_RETURN(throwScope, (toJS(lexicalGlobalObject, throwScope, impl.href()))); + const String& href = wrapped().href().string(); + if (JSString* cached = m_href.get(); cached && cached->tryGetValueImpl() == href.impl()) [[likely]] + return cached; + JSString* string = JSC::jsString(vm, href); + m_href.set(vm, this, string); + return string; +} + +static inline JSValue jsDOMURL_hrefGetter(JSGlobalObject& lexicalGlobalObject, JSDOMURL& thisObject) +{ + return thisObject.href(lexicalGlobalObject); } JSC_DEFINE_CUSTOM_GETTER(jsDOMURL_href, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue thisValue, PropertyName attributeName)) @@ -797,7 +812,7 @@ static inline JSC::EncodedJSValue jsDOMURLConstructorFunction_parseBody(JSC::JSG EnsureStillAliveScope argument1 = callFrame->argument(1); auto base = argument1.value().isUndefined() ? String() : convert(*lexicalGlobalObject, argument1.value()); RETURN_IF_EXCEPTION(throwScope, {}); - RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS>>(*lexicalGlobalObject, *uncheckedDowncast(lexicalGlobalObject), throwScope, DOMURL::parse(WTF::move(url), WTF::move(base))))); + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS>>(*lexicalGlobalObject, *uncheckedDowncast(lexicalGlobalObject), throwScope, DOMURL::parse(WTF::move(url), WTF::move(base), &WebCore::clientData(vm)->urlBaseCache())))); } JSC_DEFINE_HOST_FUNCTION(jsDOMURLConstructorFunction_parse, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) @@ -819,7 +834,7 @@ static inline JSC::EncodedJSValue jsDOMURLConstructorFunction_canParseBody(JSC:: EnsureStillAliveScope argument1 = callFrame->argument(1); auto base = argument1.value().isUndefined() ? String() : convert(*lexicalGlobalObject, argument1.value()); RETURN_IF_EXCEPTION(throwScope, {}); - RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, DOMURL::canParse(WTF::move(url), WTF::move(base))))); + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, DOMURL::canParse(WTF::move(url), WTF::move(base), &WebCore::clientData(vm)->urlBaseCache())))); } JSC_DEFINE_HOST_FUNCTION(jsDOMURLConstructorFunction_canParse, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) @@ -833,8 +848,7 @@ static inline JSC::EncodedJSValue jsDOMURLPrototypeFunction_toJSONBody(JSC::JSGl auto throwScope = DECLARE_THROW_SCOPE(vm); UNUSED_PARAM(throwScope); UNUSED_PARAM(callFrame); - auto& impl = castedThis->wrapped(); - RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.toJSON()))); + RELEASE_AND_RETURN(throwScope, JSValue::encode(castedThis->href(*lexicalGlobalObject))); } JSC_DEFINE_HOST_FUNCTION(jsDOMURLPrototypeFunction_toJSON, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) @@ -854,8 +868,7 @@ static inline JSC::EncodedJSValue jsDOMURLPrototypeFunction_toStringBody(JSC::JS auto throwScope = DECLARE_THROW_SCOPE(vm); UNUSED_PARAM(throwScope); UNUSED_PARAM(callFrame); - auto& impl = castedThis->wrapped(); - RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.href()))); + RELEASE_AND_RETURN(throwScope, JSValue::encode(castedThis->href(*lexicalGlobalObject))); } JSC_DEFINE_HOST_FUNCTION(jsDOMURLPrototypeFunction_toString, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) @@ -880,6 +893,7 @@ void JSDOMURL::visitChildrenImpl(JSCell* cell, Visitor& visitor) ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); visitor.append(thisObject->m_searchParams); + visitor.append(thisObject->m_href); visitor.reportExtraMemoryVisited(thisObject->protectedWrapped()->memoryCostForGC()); } diff --git a/src/jsc/bindings/webcore/JSDOMURL.h b/src/jsc/bindings/webcore/JSDOMURL.h index aea75c394ac..f1b76b3f24a 100644 --- a/src/jsc/bindings/webcore/JSDOMURL.h +++ b/src/jsc/bindings/webcore/JSDOMURL.h @@ -49,6 +49,10 @@ class WEBCORE_EXPORT JSDOMURL : public JSDOMWrapper { static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); mutable JSC::WriteBarrier m_searchParams; + // The last JSString handed out for href/toString/toJSON, or the constructor's argument when the URL was already + // canonical (the parsed string is then that very StringImpl), so those never allocate a second string. + mutable JSC::WriteBarrier m_href; + JSC::JSString* href(JSC::JSGlobalObject&) const; template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) { if constexpr (mode == JSC::SubspaceAccess::Concurrently) diff --git a/test/internal/source-lints/jsresult-swallow.inventory.json b/test/internal/source-lints/jsresult-swallow.inventory.json index da160b7890e..060c603c28d 100644 --- a/test/internal/source-lints/jsresult-swallow.inventory.json +++ b/test/internal/source-lints/jsresult-swallow.inventory.json @@ -7,7 +7,7 @@ "taken exception discarded": 1 }, "src/runtime/api/bun/h2_frame_parser.rs": { - "discarded result of a call that enters script": 5 + "discarded result of a call that enters script": 4 }, "src/runtime/ipc.rs": { "discarded result of a call that enters script": 4, diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index 3857ed3e9ac..41ba8ec9ee3 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -175,6 +175,81 @@ describe("url", () => { expect(new URL("https://\u{1F4A9}.com/xn--a/%41").pathname).toBe("/xn--a/%41"); }); + it("judges literal punycode labels like Node (fast path and ICU path)", () => { + // [input, canParse] — expectations match Node 26 / ICU UTS #46 (CheckBidi, CheckJoiners, non-transitional). + const cases: [string, boolean][] = [ + ["https://xn--ls8h.com/", true], // valid emoji label + ["https://XN--LS8H.com/", true], // case-insensitive prefix and digits + ["https://foo.xn--nxasmq6b/", true], // Greek + ["https://xn--mgbh0fb.xn--kgbechtv/", true], // RTL labels (BiDi rule, ICU path) + ["https://ab--cd.com/", true], // hyphens at 3-4 in a non-ACE label are allowed + ["https://xn--53h.example/", true], // single non-ASCII code point + ["https://xn--a.com/", false], // decodes to U+0080 (disallowed) + ["https://xn--/", false], // empty ACE label + ["https://xn---.com/", false], // fails Punycode decoding + ["https://xn--ascii-.com/", false], // alternate encoding of an ASCII label + ["https://xn--1ug.com/", false], // ZWJ alone (CONTEXTJ) + ["https://xn--u-ccb.com/", false], // leading combining mark + ["https://xn--0.com/", false], // truncated delta + ["https://xn--9999999999999999999999999b/", false], // overflow + ["https://xn--a-b.com/", false], // "a" + U+0080-ish: disallowed after decoding + ]; + for (const [input, ok] of cases) { + expect([input, URL.canParse(input)]).toEqual([input, ok]); + expect([input, URL.parse(input)?.href ?? null]).toEqual([input, ok ? input.toLowerCase() : null]); + if (ok) expect(new URL(input).href).toBe(input.toLowerCase()); + else expect(() => new URL(input)).toThrow(TypeError); + } + }); + + it("resolves against repeated, alternating and invalid string bases consistently", () => { + // The last successfully parsed base string is cached; make sure hits, misses and failures all behave. + const a = "https://a.example/dir/page"; + const b = "http://b.example:8080/x/y/"; + for (let i = 0; i < 3; i++) { + expect(new URL("rel", a).href).toBe("https://a.example/dir/rel"); + expect(new URL("rel", a).href).toBe("https://a.example/dir/rel"); + expect(new URL("../up", b).href).toBe("http://b.example:8080/x/up"); + expect(URL.canParse("?q", a)).toBe(true); + expect(URL.parse("#f", b)!.href).toBe("http://b.example:8080/x/y/#f"); + expect(() => new URL("rel", "not a url")).toThrow(TypeError); + expect(() => new URL("rel", "not a url")).toThrow(TypeError); + expect(URL.canParse("rel", "https://xn--a.example/")).toBe(false); + expect(URL.parse("rel", "")).toBe(null); + expect(new URL("rel", a + "\u00e9/").href).toBe("https://a.example/dir/page%C3%A9/rel"); + expect(new URL("rel", "HTTPS://A.example/dir/page").href).toBe("https://a.example/dir/rel"); + } + try { + new URL("http://[bad", a); + expect.unreachable(); + } catch (e: any) { + expect(e.code).toBe("ERR_INVALID_URL"); + expect(e.input).toBe("http://[bad"); + expect(e.base).toBe(a); + } + }); + + it("href, toString and toJSON agree before and after mutation", () => { + const s = "https://example.com/a?b#c"; + const u = new URL(s); + expect(u.href).toBe(s); + Bun.gc(true); + expect(u.toString()).toBe(s); + expect(u.toJSON()).toBe(s); + Bun.gc(true); + expect(`${u}`).toBe(s); + u.pathname = "/z"; + Bun.gc(true); + expect(u.href).toBe("https://example.com/z?b#c"); + expect(u.toString()).toBe(u.href); + u.searchParams.append("d", "1"); + expect(u.toJSON()).toBe("https://example.com/z?b=&d=1#c"); + u.href = "http://other/"; + expect([u.href, String(u), JSON.stringify(u)]).toEqual(["http://other/", "http://other/", '"http://other/"']); + const v = new URL("HTTP://Example.COM"); + expect(v.href).toBe("http://example.com/"); + }); + it("prints", () => { // URL.prototype carries [Symbol.for("nodejs.util.inspect.custom")], so // Bun.inspect matches node's util.inspect output.