From 07395767c9828d1d8c0e7381509d7de9f928a52d Mon Sep 17 00:00:00 2001 From: Don Isaac Date: Tue, 14 Jan 2025 17:24:22 -0800 Subject: [PATCH] add builtin --- src/bun.js/bindings/BunObject.cpp | 50 +- src/bun.js/bindings/ErrorCode.cpp | 28 +- src/bun.js/bindings/ErrorCode.h | 10 + src/bun.js/bindings/ErrorCode.ts | 24 +- src/bun.js/bindings/PathInlines.h | 16 +- src/bun.js/bindings/ZigGlobalObject.cpp | 2 +- src/bun.js/bindings/bindings.cpp | 26 + src/bun.js/node/node_util_binding.zig | 15 +- src/codegen/generate-node-errors.ts | 1 + src/js/builtins.d.ts | 33 +- src/js/builtins/BunBuiltinNames.h | 1 + src/js/internal/util.ts | 5 + src/js/node/url.ts | 416 ++++++++++------ test/js/bun/plugin/plugins.test.ts | 8 +- test/js/bun/util/fileUrl.test.js | 5 +- test/js/node/harness.ts | 17 +- .../needs-test/test-url-fileurltopath.js | 177 +++++++ .../test-url-format-invalid-input.js | 32 ++ .../test/parallel/test-url-is-url-internal.js | 24 + .../parallel/test-url-parse-invalid-input.js | 107 +++++ .../test/parallel/test-url-parse-query.js | 101 ++++ .../node/test/parallel/test-url-relative.js | 443 ++++++++++++++++++ test/js/node/url/url-parse-ipv6.test.ts | 190 ++++++++ 23 files changed, 1563 insertions(+), 168 deletions(-) create mode 100644 src/js/internal/util.ts create mode 100644 test/js/node/test/parallel/needs-test/test-url-fileurltopath.js create mode 100644 test/js/node/test/parallel/needs-test/test-url-format-invalid-input.js create mode 100644 test/js/node/test/parallel/test-url-is-url-internal.js create mode 100644 test/js/node/test/parallel/test-url-parse-invalid-input.js create mode 100644 test/js/node/test/parallel/test-url-parse-query.js create mode 100644 test/js/node/test/parallel/test-url-relative.js create mode 100644 test/js/node/url/url-parse-ipv6.test.ts diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 2dc702a59a229a..5aa13b44d08808 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -604,23 +604,67 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj url = WTF::URL(arg0.toWTFString(globalObject)); RETURN_IF_EXCEPTION(scope, {}); } else { - throwTypeError(globalObject, scope, "Argument must be a URL"_s); + Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "url"_s, "string"_s, arg0); return {}; } } else { url = domURL->href(); } + /// cannot turn non-`file://` URLs into file paths if (UNLIKELY(!url.protocolIsFile())) { - throwTypeError(globalObject, scope, "Argument must be a file URL"_s); + Bun::ERR::INVALID_URL_SCHEME(scope, globalObject, "file"_s); return {}; } +// NOTE: On Windows, WTF::URL::fileSystemPath will handle UNC paths +// (`file:\\server\share\etc` -> `\\server\share\etc`), so hostname check only +// needs to happen on posix systems +#if !OS(WINDOWS) + // file://host/path is illegal if `host` is not `localhost`. + // Should be `file:///` instead + if (UNLIKELY(url.host().length() > 0 && url.host() != "localhost"_s)) { + +#if OS(DARWIN) + Bun::ERR::INVALID_FILE_URL_HOST(scope, globalObject, "darwin"_s); + return {}; +#else + Bun::ERR::INVALID_FILE_URL_HOST(scope, globalObject, "linux"_s); + return {}; +#endif + } +#endif + + // ban url-encoded slashes. '/' on posix, '/' and '\' on windows. + StringView p = url.path(); + if (p.length() > 3) { + for (int i = 0; i < p.length() - 2; i++) { + if (p[i] == '%') { + const char second = p[i + 1]; + const uint8_t third = p[i + 2] | 0x20; +#if OS(WINDOWS) + if ( + (second == '2' && third == 102) || // 2f 2F '/' + (second == '5' && third == 99) // 5c 5C '\' + ) { + Bun::ERR::INVALID_FILE_URL_PATH(scope, globalObject, "must not include encoded \\ or / characters"_s); + return {}; + } +#else + if (second == '2' && third == 102) { + Bun::ERR::INVALID_FILE_URL_PATH(scope, globalObject, "must not include encoded / characters"_s); + return {}; + } +#endif + } + } + } + auto fileSystemPath = url.fileSystemPath(); #if OS(WINDOWS) if (!isAbsolutePath(fileSystemPath)) { - throwTypeError(globalObject, scope, "File URL path must be absolute"_s); + Bun::ERR::INVALID_FILE_URL_PATH(scope, globalObject, "must be an absolute path"_s); return {}; } #endif diff --git a/src/bun.js/bindings/ErrorCode.cpp b/src/bun.js/bindings/ErrorCode.cpp index b2d19dc3c61b6c..c82651d73a4f94 100644 --- a/src/bun.js/bindings/ErrorCode.cpp +++ b/src/bun.js/bindings/ErrorCode.cpp @@ -567,6 +567,32 @@ JSC::EncodedJSValue INVALID_ARG_VALUE(JSC::ThrowScope& throwScope, JSC::JSGlobal return {}; } +JSC::EncodedJSValue INVALID_URL_SCHEME(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::String& expectedScheme) +{ + auto message = makeString("The URL must be of scheme "_s, expectedScheme); + throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_INVALID_URL_SCHEME, message)); + return {}; +} +JSC::EncodedJSValue INVALID_FILE_URL_HOST(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::String& platform) +{ + auto message = makeString("File URL host must be \"localhost\" or empty on "_s, platform); + throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_INVALID_FILE_URL_HOST, message)); + return {}; +} +JSC::EncodedJSValue INVALID_FILE_URL_HOST(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const ASCIILiteral platform) +{ + auto message = makeString("File URL host must be \"localhost\" or empty on "_s, platform); + throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_INVALID_FILE_URL_HOST, message)); + return {}; +} +/// `File URL path {suffix}` +JSC::EncodedJSValue INVALID_FILE_URL_PATH(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const ASCIILiteral suffix) +{ + auto message = makeString("File URL path "_s, suffix); + throwScope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_INVALID_FILE_URL_PATH, message)); + return {}; +} + JSC::EncodedJSValue UNKNOWN_ENCODING(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::StringView encoding) { auto message = makeString("Unknown encoding: "_s, encoding); @@ -655,7 +681,7 @@ JSC::EncodedJSValue CRYPTO_JWK_UNSUPPORTED_CURVE(JSC::ThrowScope& throwScope, JS return {}; } -} +} // namespace ERR static JSC::JSValue ERR_INVALID_ARG_TYPE(JSC::ThrowScope& scope, JSC::JSGlobalObject* globalObject, JSValue arg0, JSValue arg1, JSValue arg2) { diff --git a/src/bun.js/bindings/ErrorCode.h b/src/bun.js/bindings/ErrorCode.h index 932c6c3203c043..226201199b2fcb 100644 --- a/src/bun.js/bindings/ErrorCode.h +++ b/src/bun.js/bindings/ErrorCode.h @@ -1,3 +1,4 @@ +// To add a new error code, put it in ErrorCode.ts #pragma once #include "ZigGlobalObject.h" @@ -86,6 +87,15 @@ JSC::EncodedJSValue ASSERTION(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* JSC::EncodedJSValue CRYPTO_INVALID_CURVE(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject); JSC::EncodedJSValue CRYPTO_JWK_UNSUPPORTED_CURVE(JSC::ThrowScope&, JSC::JSGlobalObject*, const WTF::String&); +// URL + +/// `URL must be of scheme {expectedScheme}` +JSC::EncodedJSValue INVALID_URL_SCHEME(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::String& expectedScheme); +/// `File URL host must be "localhost" or empty on {platform}` +JSC::EncodedJSValue INVALID_FILE_URL_HOST(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const WTF::String& platform); +/// `File URL path {suffix}` +JSC::EncodedJSValue INVALID_FILE_URL_PATH(JSC::ThrowScope& throwScope, JSC::JSGlobalObject* globalObject, const ASCIILiteral suffix); + } void throwBoringSSLError(JSC::VM& vm, JSC::ThrowScope& scope, JSGlobalObject* globalObject, int errorCode); diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 530eac26657e99..caedaecb2a7e38 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -2,11 +2,11 @@ type ErrorCodeMapping = Array< [ /** error.code */ - string, + code: string, /** Constructor **/ - typeof TypeError | typeof RangeError | typeof Error | typeof SyntaxError, + ctor: typeof TypeError | typeof RangeError | typeof Error | typeof SyntaxError, /** error.name. Defaults to `Constructor.name` (that is, mapping[1].name */ - string?, + name?: string, (typeof TypeError | typeof RangeError | typeof Error | typeof SyntaxError)?, (typeof TypeError | typeof RangeError | typeof Error | typeof SyntaxError)?, ] @@ -54,6 +54,11 @@ const errors: ErrorCodeMapping = [ ["ERR_PARSE_ARGS_INVALID_OPTION_VALUE", TypeError], ["ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL", TypeError], ["ERR_PARSE_ARGS_UNKNOWN_OPTION", TypeError], + ["ERR_STREAM_ALREADY_FINISHED", TypeError], + ["ERR_STREAM_CANNOT_PIPE", TypeError], + ["ERR_STREAM_DESTROYED", TypeError], + ["ERR_STREAM_NULL_VALUES", TypeError], + ["ERR_STREAM_WRITE_AFTER_END", TypeError], ["ERR_SCRIPT_EXECUTION_INTERRUPTED", Error], ["ERR_SCRIPT_EXECUTION_TIMEOUT", Error], ["ERR_SERVER_NOT_RUNNING", Error], @@ -64,16 +69,11 @@ const errors: ErrorCodeMapping = [ ["ERR_SOCKET_DGRAM_IS_CONNECTED", Error], ["ERR_SOCKET_DGRAM_NOT_CONNECTED", Error], ["ERR_SOCKET_DGRAM_NOT_RUNNING", Error], - ["ERR_STREAM_ALREADY_FINISHED", Error], - ["ERR_STREAM_CANNOT_PIPE", Error], - ["ERR_STREAM_DESTROYED", Error], - ["ERR_STREAM_NULL_VALUES", TypeError], ["ERR_STREAM_PREMATURE_CLOSE", Error], ["ERR_STREAM_PUSH_AFTER_EOF", Error], ["ERR_STREAM_RELEASE_LOCK", Error, "AbortError"], ["ERR_STREAM_UNABLE_TO_PIPE", Error], ["ERR_STREAM_UNSHIFT_AFTER_END_EVENT", Error], - ["ERR_STREAM_WRITE_AFTER_END", Error], ["ERR_STRING_TOO_LONG", Error], ["ERR_UNAVAILABLE_DURING_EXIT", Error], ["ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET", Error], @@ -176,5 +176,11 @@ const errors: ErrorCodeMapping = [ ["ERR_S3_INVALID_ENDPOINT", Error], ["ERR_S3_INVALID_SIGNATURE", Error], ["ERR_S3_INVALID_SESSION_TOKEN", Error], -] as const; + + // URL + ["ERR_INVALID_URL", TypeError], + ["ERR_INVALID_URL_SCHEME", TypeError], + ["ERR_INVALID_FILE_URL_HOST", TypeError], + ["ERR_INVALID_FILE_URL_PATH", TypeError], +]; export default errors; diff --git a/src/bun.js/bindings/PathInlines.h b/src/bun.js/bindings/PathInlines.h index f39445dc28be56..363d9269a0d3a2 100644 --- a/src/bun.js/bindings/PathInlines.h +++ b/src/bun.js/bindings/PathInlines.h @@ -14,6 +14,11 @@ #define PLATFORM_SEP POSIX_PATH_SEP #endif +#define IS_LETTER(byte) \ + ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z')) +#define IS_SLASH(byte) \ + (byte == '/' || byte == '\\') + ALWAYS_INLINE bool isAbsolutePath(WTF::String input) { #if OS(WINDOWS) @@ -22,11 +27,11 @@ ALWAYS_INLINE bool isAbsolutePath(WTF::String input) if (len < 1) return false; const auto bytes = input.span8().data(); - if (bytes[0] == '/' || bytes[0] == '\\') + if (IS_SLASH(bytes[0])) return true; if (len < 2) return false; - if (bytes[1] == ':' && (bytes[2] == '/' || bytes[2] == '\\')) + if (IS_LETTER(bytes[0]) && bytes[1] == ':' && IS_SLASH(bytes[2])) return true; return false; } else { @@ -34,11 +39,11 @@ ALWAYS_INLINE bool isAbsolutePath(WTF::String input) if (len < 1) return false; const auto bytes = input.span16().data(); - if (bytes[0] == '/' || bytes[0] == '\\') + if (IS_SLASH(bytes[0])) return true; if (len < 2) return false; - if (bytes[1] == ':' && (bytes[2] == '/' || bytes[2] == '\\')) + if (IS_LETTER(bytes[0]) && bytes[1] == ':' && IS_SLASH(bytes[2])) return true; return false; } @@ -47,6 +52,9 @@ ALWAYS_INLINE bool isAbsolutePath(WTF::String input) #endif } +#undef IS_LETTER +#undef IS_SLASH + extern "C" BunString ResolvePath__joinAbsStringBufCurrentPlatformBunString(JSC::JSGlobalObject*, BunString); /// CWD is determined by the global object's current cwd. diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 016fa82433d72c..2700e38c0e1c01 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -1,4 +1,3 @@ - #include "root.h" #include "ZigGlobalObject.h" @@ -3618,6 +3617,7 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) putDirectBuiltinFunction(vm, this, builtinNames.overridableRequirePrivateName(), moduleOverridableRequireCodeGenerator(vm), 0); + putDirectNativeFunction(vm, this, builtinNames.decodeURIComponentPrivateName(), 1, JSC::globalFuncDecodeURIComponent, ImplementationVisibility::Public, NoIntrinsic, PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); putDirectNativeFunction(vm, this, builtinNames.createUninitializedArrayBufferPrivateName(), 1, functionCreateUninitializedArrayBuffer, ImplementationVisibility::Public, NoIntrinsic, PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); putDirectNativeFunction(vm, this, builtinNames.resolveSyncPrivateName(), 1, functionImportMeta__resolveSyncPrivate, ImplementationVisibility::Public, NoIntrinsic, PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); putDirectNativeFunction(vm, this, builtinNames.createInternalModuleByIdPrivateName(), 1, InternalModuleRegistry::jsCreateInternalModuleById, ImplementationVisibility::Public, NoIntrinsic, PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 4ec0afd6aecffe..2be795f0945473 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -6346,3 +6346,29 @@ extern "C" EncodedJSValue Bun__JSObject__getCodePropertyVMInquiry(JSC::JSGlobalO return JSValue::encode(slot.getPureResult()); } + +using StackCodeType = JSC::StackVisitor::Frame::CodeType; +CPP_DECL bool Bun__util__isInsideNodeModules(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame) +{ + JSC::VM& vm = globalObject->vm(); + bool inNodeModules = false; + JSC::StackVisitor::visit(callFrame, vm, [&](JSC::StackVisitor& visitor) -> WTF::IterationStatus { + if (Zig::isImplementationVisibilityPrivate(visitor) || visitor->isNativeCalleeFrame()) { + return WTF::IterationStatus::Continue; + } + + if (visitor->hasLineAndColumnInfo()) { + String sourceURL = Zig::sourceURL(visitor); + if (sourceURL.startsWith("node:"_s) || sourceURL.startsWith("bun:"_s)) + return WTF::IterationStatus::Continue; + if (sourceURL.contains("node_modules"_s)) + inNodeModules = true; + + return WTF::IterationStatus::Done; + } + + return WTF::IterationStatus::Continue; + }); + + return inNodeModules; +} diff --git a/src/bun.js/node/node_util_binding.zig b/src/bun.js/node/node_util_binding.zig index 6acfccc72540c1..4c982e3f88e5f3 100644 --- a/src/bun.js/node/node_util_binding.zig +++ b/src/bun.js/node/node_util_binding.zig @@ -132,7 +132,20 @@ pub fn extractedSplitNewLinesFastPathStringsOnly(globalThis: *JSC.JSGlobalObject }; } -fn split(comptime encoding: bun.strings.EncodingNonAscii, globalThis: *JSC.JSGlobalObject, allocator: Allocator, str: *const bun.String) bun.JSError!JSC.JSValue { +extern fn Bun__util__isInsideNodeModules(globalObject: *JSC.JSGlobalObject, callFrame: *JSC.CallFrame) bool; +/// Walks the call stack from bottom to top, returning `true` when it finds a +/// frame within a `node_modules` directory. +pub fn isInsideNodeModules(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + const res = Bun__util__isInsideNodeModules(globalObject, callframe); + return JSC.JSValue.jsBoolean(res); +} + +fn split( + comptime encoding: bun.strings.EncodingNonAscii, + globalThis: *JSC.JSGlobalObject, + allocator: Allocator, + str: *const bun.String, +) bun.JSError!JSC.JSValue { var fallback = std.heap.stackFallback(1024, allocator); const alloc = fallback.get(); const Char = switch (encoding) { diff --git a/src/codegen/generate-node-errors.ts b/src/codegen/generate-node-errors.ts index f7ba157fea2b02..0b5cc189a75939 100644 --- a/src/codegen/generate-node-errors.ts +++ b/src/codegen/generate-node-errors.ts @@ -25,6 +25,7 @@ let zig = ``; enumHeader = ` // clang-format off // Generated by: src/codegen/generate-node-errors.ts +// Input: src/bun.js/bindings/ErrorCode.ts #pragma once #include diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 46b9d87da7ad2d..f8dfd59f16c74a 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -73,11 +73,15 @@ declare function $getByIdDirectPrivate(obj: any, key: string): T; declare function $getByValWithThis(target: any, receiver: any, propertyKey: string): void; /** gets the prototype of an object */ declare function $getPrototypeOf(value: any): any; -/** gets an internal property on a promise +/** + * Gets an internal property on a promise * * You can pass - * - $promiseFieldFlags - get a number with flags - * - $promiseFieldReactionsOrResult - get the result (like Bun.peek) + * - {@link $promiseFieldFlags} - get a number with flags + * - {@link $promiseFieldReactionsOrResult} - get the result (like {@link Bun.peek}) + * + * @param promise the promise to get the field from + * @param key an internal field id. */ declare function $getPromiseInternalField( promise: Promise, @@ -99,6 +103,19 @@ declare function $getMapIteratorInternalField(): TODO; declare function $getSetIteratorInternalField(): TODO; declare function $getProxyInternalField(): TODO; declare function $idWithProfile(): TODO; +/** + * True for object-like `JSCell`s. That is, this is roughly equivalent to this + * JS code: + * ```js + * typeof obj === "object" && obj !== null + * ``` + * + * @param obj The object to check + * @returns `true` if `obj` is an object-like `JSCell` + * + * @see [JSCell.h](https://github.com/oven-sh/WebKit/blob/main/Source/JavaScriptCore/runtime/JSCell.h) + * @see [JIT implementation](https://github.com/oven-sh/WebKit/blob/433f7598bf3537a295d0af5ffd83b9a307abec4e/Source/JavaScriptCore/jit/JITOpcodes.cpp#L311) + */ declare function $isObject(obj: unknown): obj is object; declare function $isArray(obj: unknown): obj is any[]; declare function $isCallable(fn: unknown): fn is CallableFunction; @@ -559,6 +576,11 @@ declare interface Function { path: string; } +interface String { + $charCodeAt: String["charCodeAt"]; + // add others as needed +} + declare var $Buffer: { new (a: any, b?: any, c?: any): Buffer; }; @@ -618,3 +640,8 @@ declare function $ERR_ILLEGAL_CONSTRUCTOR(): TypeError; * @param base - The base class to inherit from */ declare function $toClass(fn: Function, name: string, base?: Function | undefined | null); +/** + * Gets the unencoded version of an encoded component of a Uniform Resource Identifier (URI). + * @param encodedURIComponent A value representing an encoded URI component. + */ +declare function $decodeURIComponent(encodedURIComponent: string): string; diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index 7fd2016a106ffc..81fa916b782759 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -259,6 +259,7 @@ using namespace JSC; macro(written) \ macro(napiDlopenHandle) \ macro(napiWrappedContents) \ + macro(decodeURIComponent) \ BUN_ADDITIONAL_BUILTIN_NAMES(macro) // --- END of BUN_COMMON_PRIVATE_IDENTIFIERS_EACH_PROPERTY_NAME --- diff --git a/src/js/internal/util.ts b/src/js/internal/util.ts new file mode 100644 index 00000000000000..32db0807c34557 --- /dev/null +++ b/src/js/internal/util.ts @@ -0,0 +1,5 @@ +const isInsideNodeModules: () => boolean = $newZigFunction("node_util_binding.zig", "isInsideNodeModules", 0); + +export default { + isInsideNodeModules, +}; diff --git a/src/js/node/url.ts b/src/js/node/url.ts index d969ab08ff27ca..86583532ce4f92 100644 --- a/src/js/node/url.ts +++ b/src/js/node/url.ts @@ -1,3 +1,5 @@ +/// + /* * Copyright Joyent, Inc. and other Node contributors. * @@ -26,6 +28,12 @@ const { URL, URLSearchParams } = globalThis; const [domainToASCII, domainToUnicode] = $cpp("NodeURL.cpp", "Bun::createNodeURLBinding"); const { urlToHttpOptions } = require("internal/url"); +const { validateString } = require("internal/validators"); + +var _lazyUtil; +function lazyUtil(): (typeof import("internal/util"))["default"] { + return (_lazyUtil ||= require("internal/util")); +} function Url() { this.protocol = null; @@ -97,44 +105,113 @@ var protocolPattern = /^([a-z0-9.+-]+:)/i, "file:": true, }; -function urlParse(url, parseQueryString, slashesDenoteHost) { - if (url && typeof url === "object" && url instanceof Url) { - return url; - } +let urlParseWarned = false; +function urlParse( + url: string | URL | Url, // really has unknown type but intellisense is nice + parseQueryString?: boolean, + slashesDenoteHost?: boolean, +) { + if (!urlParseWarned && !lazyUtil().isInsideNodeModules()) { + urlParseWarned = true; + process.emitWarning( + "`url.parse()` behavior is not standardized and prone to " + + "errors that have security implications. Use the WHATWG URL API " + + "instead. CVEs are not issued for `url.parse()` vulnerabilities.", + "DeprecationWarning", + "DEP0169", + ); + } + + if ($isObject(url) && url instanceof Url) return url; var u = new Url(); - u.parse(url, parseQueryString, slashesDenoteHost); + try { + u.parse(url, parseQueryString, slashesDenoteHost); + } catch (e) { + $putByIdDirect(e, "input", url); + throw e; + } return u; } -Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { - if (typeof url !== "string") { - throw new TypeError("Parameter 'url' must be a string, not " + typeof url); - } +Url.prototype.parse = function parse(url: string, parseQueryString?: boolean, slashesDenoteHost?: boolean) { + validateString(url, "url"); /* * Copy chrome, IE, opera backslash-handling behavior. * Back slashes before the query string get converted to forward slashes * See: https://code.google.com/p/chromium/issues/detail?id=25916 */ - var queryIndex = url.indexOf("?"), - splitter = queryIndex !== -1 && queryIndex < url.indexOf("#") ? "?" : "#", - uSplit = url.split(splitter), - slashRegex = /\\/g; - uSplit[0] = uSplit[0].replace(slashRegex, "/"); - url = uSplit.join(splitter); + let hasHash = false; + let hasAt = false; + let start = -1; + let end = -1; + let rest = ""; + let lastPos = 0; + for (let i = 0, inWs = false, split = false; i < url.length; ++i) { + const code = url.$charCodeAt(i); + + // Find first and last non-whitespace characters for trimming + const isWs = code < 33 || code === Char.NO_BREAK_SPACE || code === Char.ZERO_WIDTH_NOBREAK_SPACE; + if (start === -1) { + if (isWs) continue; + lastPos = start = i; + } else if (inWs) { + if (!isWs) { + end = -1; + inWs = false; + } + } else if (isWs) { + end = i; + inWs = true; + } - var rest = url; + // Only convert backslashes while we haven't seen a split character + if (!split) { + switch (code) { + case Char.AT: + hasAt = true; + break; + case Char.HASH: + hasHash = true; + // Fall through + case Char.QUESTION_MARK: + split = true; + break; + case Char.BACKWARD_SLASH: + if (i - lastPos > 0) rest += url.slice(lastPos, i); + rest += "/"; + lastPos = i + 1; + break; + } + } else if (!hasHash && code === Char.HASH) { + hasHash = true; + } + } - /* - * trim before proceeding. - * This is to support parse stuff like " http://foo.com \n" - */ - rest = rest.trim(); + // Check if string was non-empty (including strings with only whitespace) + if (start !== -1) { + if (lastPos === start) { + // We didn't convert any backslashes + + if (end === -1) { + if (start === 0) rest = url; + else rest = url.slice(start); + } else { + rest = url.slice(start, end); + } + } else if (end === -1 && lastPos < url.length) { + // We converted some backslashes and have only part of the entire string + rest += url.slice(lastPos); + } else if (end !== -1 && lastPos < end) { + // We converted some backslashes and have only part of the entire string + rest += url.slice(lastPos, end); + } + } - if (!slashesDenoteHost && url.split("#").length === 1) { + if (!slashesDenoteHost && !hasHash && !hasAt) { // Try fast path regexp - var simplePath = simplePathPattern.exec(rest); + const simplePath = simplePathPattern.exec(rest); if (simplePath) { this.path = rest; this.href = rest; @@ -142,24 +219,24 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { if (simplePath[2]) { this.search = simplePath[2]; if (parseQueryString) { - this.query = new URLSearchParams(this.search.substr(1)).toJSON(); + this.query = new URLSearchParams(this.search.slice(1)).toJSON(); } else { - this.query = this.search.substr(1); + this.query = this.search.slice(1); } } else if (parseQueryString) { - this.search = ""; - this.query = {}; + this.search = null; + this.query = { __proto__: null }; } return this; } } - var proto = protocolPattern.exec(rest); + var proto: any = protocolPattern.exec(rest); if (proto) { proto = proto[0]; var lowerProto = proto.toLowerCase(); this.protocol = lowerProto; - rest = rest.substr(proto.length); + rest = rest.substring(proto.length); } /* @@ -169,9 +246,9 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { * how the browser resolves relative URLs. */ if (slashesDenoteHost || proto || rest.match(/^\/\/[^@/]+@[^@/]+/)) { - var slashes = rest.substr(0, 2) === "//"; + var slashes = rest.substring(0, 2) === "//"; if (slashes && !(proto && hostlessProtocol[proto])) { - rest = rest.substr(2); + rest = rest.substring(2); this.slashes = true; } } @@ -209,7 +286,7 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { * at this point, either we have an explicit point where the * auth portion cannot go past, or the last @ char is the decider. */ - var auth, atSign; + var auth: string | undefined, atSign: number; if (hostEnd === -1) { // atSign can be anywhere. atSign = rest.lastIndexOf("@"); @@ -228,7 +305,7 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { if (atSign !== -1) { auth = rest.slice(0, atSign); rest = rest.slice(atSign + 1); - this.auth = decodeURIComponent(auth); + this.auth = $decodeURIComponent(auth); } // the host is the remaining to the left of the first non-host char @@ -254,53 +331,20 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { * we've indicated that there is a hostname, * so even if it's empty, it has to be present. */ - this.hostname = this.hostname || ""; + if (typeof this.hostname !== "string") { + this.hostname = ""; + } + const hostname = this.hostname; /* * if hostname begins with [ and ends with ] * assume that it's an IPv6 address. */ - var ipv6Hostname = this.hostname[0] === "[" && this.hostname[this.hostname.length - 1] === "]"; + var ipv6Hostname = isIpv6Hostname(this.hostname); // validate a little. if (!ipv6Hostname) { - var hostparts = this.hostname.split(/\./); - for (var i = 0, l = hostparts.length; i < l; i++) { - var part = hostparts[i]; - if (!part) { - continue; - } - if (!part.match(hostnamePartPattern)) { - var newpart = ""; - for (var j = 0, k = part.length; j < k; j++) { - if (part.charCodeAt(j) > 127) { - /* - * we replace non-ASCII char with a temporary placeholder - * we need this to make sure size of hostname is not - * broken by replacing non-ASCII by nothing - */ - newpart += "x"; - } else { - newpart += part[j]; - } - } - // we test again with ASCII char only - if (!newpart.match(hostnamePartPattern)) { - var validParts = hostparts.slice(0, i); - var notHost = hostparts.slice(i + 1); - var bit = part.match(hostnamePartStart); - if (bit) { - validParts.push(bit[1]); - notHost.unshift(bit[2]); - } - if (notHost.length) { - rest = "/" + notHost.join(".") + rest; - } - this.hostname = validParts.join("."); - break; - } - } - } + rest = getHostname(this, rest, hostname, url); } if (this.hostname.length > hostnameMaxLen) { @@ -330,7 +374,7 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { * the host field still retains them, though */ if (ipv6Hostname) { - this.hostname = this.hostname.substr(1, this.hostname.length - 2); + this.hostname = this.hostname.slice(1, -1); if (rest[0] !== "/") { rest = "/" + rest; } @@ -364,13 +408,13 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { var hash = rest.indexOf("#"); if (hash !== -1) { // got a fragment string. - this.hash = rest.substr(hash); + this.hash = rest.substring(hash); rest = rest.slice(0, hash); } var qm = rest.indexOf("?"); if (qm !== -1) { - this.search = rest.substr(qm); - this.query = rest.substr(qm + 1); + this.search = rest.substring(qm); + this.query = rest.substring(qm + 1); if (parseQueryString) { const query = this.query; this.query = new URLSearchParams(query).toJSON(); @@ -378,7 +422,7 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { rest = rest.slice(0, qm); } else if (parseQueryString) { // no query string, but parseQueryString still requested - this.search = ""; + this.search = null; this.query = {}; } if (rest) { @@ -400,34 +444,73 @@ Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { return this; }; +function isIpv6Hostname(hostname: string) { + return ( + hostname.$charCodeAt(0) === Char.LEFT_SQUARE_BRACKET && + hostname.$charCodeAt($toLength(hostname.length - 1)) === Char.RIGHT_SQUARE_BRACKET + ); +} + +let warnInvalidPort = true; +function getHostname(self, rest, hostname: string, url) { + for (let i = 0; i < hostname.length; ++i) { + const code = hostname.$charCodeAt(i); + const isValid = + code !== Char.FORWARD_SLASH && + code !== Char.BACKWARD_SLASH && + code !== Char.HASH && + code !== Char.QUESTION_MARK && + code !== Char.COLON; + + if (!isValid) { + // If leftover starts with :, then it represents an invalid port. + // But url.parse() is lenient about it for now. + // Issue a warning and continue. + if (warnInvalidPort && code === Char.COLON) { + const detail = `The URL ${url} is invalid. Future versions of Node.js will throw an error.`; + process.emitWarning(detail, "DeprecationWarning", "DEP0170"); + warnInvalidPort = false; + } + self.hostname = hostname.slice(0, i); + return `/${hostname.slice(i)}${rest}`; + } + } + return rest; +} + // format a parsed object into a url string -function urlFormat(obj) { +declare function urlFormat(urlObject: string | URL | Url): string; +function urlFormat(urlObject: unknown) { /* * ensure it's an object, and not a string url. * If it's an obj, this is a no-op. * this way, you can call url_format() on strings * to clean up potentially wonky urls. */ - if (typeof obj === "string") { - obj = urlParse(obj); + if (typeof urlObject === "string") { + urlObject = urlParse(urlObject); + // NOTE: $isObject returns true for functions + } else if (typeof urlObject !== "object" || urlObject === null) { + throw $ERR_INVALID_ARG_TYPE("urlObject", ["Object", "string"], urlObject); } - if (!(obj instanceof Url)) { - return Url.prototype.format.$call(obj); + + if (!(urlObject instanceof Url)) { + return Url.prototype.format.$call(urlObject); } - return obj.format(); + return urlObject.format(); } -Url.prototype.format = function () { - var auth = this.auth || ""; +Url.prototype.format = function format() { + var auth: string = this.auth || ""; if (auth) { auth = encodeURIComponent(auth); auth = auth.replace(/%3A/i, ":"); auth += "@"; } - var protocol = this.protocol || "", - pathname = this.pathname || "", - hash = this.hash || "", + var protocol: string = this.protocol || "", + pathname: string = this.pathname || "", + hash: string = this.hash || "", host = "", query = ""; @@ -478,11 +561,11 @@ Url.prototype.format = function () { return protocol + host + pathname + search + hash; }; -function urlResolve(source, relative) { +function urlResolve(source: string | URL | Url, relative: string | URL | Url) { return urlParse(source, false, true).resolve(relative); } -Url.prototype.resolve = function (relative) { +Url.prototype.resolve = function resolve(relative: string | URL | Url) { return this.resolveObject(urlParse(relative, false, true)).format(); }; @@ -493,7 +576,7 @@ function urlResolveObject(source, relative) { return urlParse(source, false, true).resolveObject(relative); } -Url.prototype.resolveObject = function (relative) { +Url.prototype.resolveObject = function resolveObject(relative) { if (typeof relative === "string") { var rel = new Url(); rel.parse(relative, false, true); @@ -562,21 +645,18 @@ Url.prototype.resolveObject = function (relative) { } result.protocol = relative.protocol; - if (!relative.host && !hostlessProtocol[relative.protocol]) { - var relPath = (relative.pathname || "").split("/"); + if ( + !relative.host && + !(relative.protocol === "file" || relative.protocol === "file:") && + !hostlessProtocol[relative.protocol] + ) { + let relPath = (relative.pathname || "").split("/"); while (relPath.length && !(relative.host = relPath.shift())) {} - if (!relative.host) { - relative.host = ""; - } - if (!relative.hostname) { - relative.hostname = ""; - } - if (relPath[0] !== "") { - relPath.unshift(""); - } - if (relPath.length < 2) { - relPath.unshift(""); - } + relative.host ||= ""; + relative.hostname ||= ""; + if (relPath[0] !== "") relPath.unshift(""); + if (relPath.length < 2) relPath.unshift(""); + result.pathname = relPath.join("/"); } else { result.pathname = relative.pathname; @@ -598,13 +678,13 @@ Url.prototype.resolveObject = function (relative) { return result; } - var isSourceAbs = result.pathname && result.pathname.charAt(0) === "/", - isRelAbs = relative.host || (relative.pathname && relative.pathname.charAt(0) === "/"), - mustEndAbs = isRelAbs || isSourceAbs || (result.host && relative.pathname), - removeAllDots = mustEndAbs, - srcPath = (result.pathname && result.pathname.split("/")) || [], - relPath = (relative.pathname && relative.pathname.split("/")) || [], - psychotic = result.protocol && !slashedProtocol[result.protocol]; + const isSourceAbs = result.pathname && result.pathname.charAt(0) === "/"; + const isRelAbs = relative.host || (relative.pathname && relative.pathname.charAt(0) === "/"); + let mustEndAbs = isRelAbs || isSourceAbs || (result.host && relative.pathname); + const removeAllDots = mustEndAbs; + let srcPath = (result.pathname && result.pathname.split("/")) || []; + const relPath = (relative.pathname && relative.pathname.split("/")) || []; + const psychotic = result.protocol && !slashedProtocol[result.protocol]; /* * if the url is a non-slashed url, then relative @@ -617,16 +697,14 @@ Url.prototype.resolveObject = function (relative) { result.hostname = ""; result.port = null; if (result.host) { - if (srcPath[0] === "") { - srcPath[0] = result.host; - } else { - srcPath.unshift(result.host); - } + if (srcPath[0] === "") srcPath[0] = result.host; + else srcPath.unshift(result.host); } result.host = ""; if (relative.protocol) { relative.hostname = null; relative.port = null; + result.auth = null; if (relative.host) { if (relPath[0] === "") { relPath[0] = relative.host; @@ -636,13 +714,20 @@ Url.prototype.resolveObject = function (relative) { } relative.host = null; } - mustEndAbs = mustEndAbs && (relPath[0] === "" || srcPath[0] === ""); + mustEndAbs &&= relPath[0] === "" || srcPath[0] === ""; } if (isRelAbs) { // it's absolute. - result.host = relative.host || relative.host === "" ? relative.host : result.host; - result.hostname = relative.hostname || relative.hostname === "" ? relative.hostname : result.hostname; + if (relative.host || relative.host === "") { + if (result.host !== relative.host) result.auth = null; + result.host = relative.host; + result.port = relative.port; + } + if (relative.hostname || relative.hostname === "") { + if (result.hostname !== relative.hostname) result.auth = null; + result.hostname = relative.hostname; + } result.search = relative.search; result.query = relative.query; srcPath = relPath; @@ -652,22 +737,19 @@ Url.prototype.resolveObject = function (relative) { * it's relative * throw away the existing file, and take the new path instead. */ - if (!srcPath) { - srcPath = []; - } + srcPath ||= []; srcPath.pop(); srcPath = srcPath.concat(relPath); result.search = relative.search; result.query = relative.query; - } else if (relative.search != null) { + } else if (relative.search != null && relative.search !== undefined) { /* * just pull out the search. * like href='?foo'. * Put this after the other two cases because it simplifies the booleans */ if (psychotic) { - result.host = srcPath.shift(); - result.hostname = result.host; + result.hostname = result.host = srcPath.shift(); /* * occationaly the auth can get stuck only in host * this especially happens in cases like @@ -676,15 +758,16 @@ Url.prototype.resolveObject = function (relative) { var authInHost = result.host && result.host.indexOf("@") > 0 ? result.host.split("@") : false; if (authInHost) { result.auth = authInHost.shift(); - result.hostname = authInHost.shift(); - result.host = result.hostname; + result.hostname = result.host = authInHost.shift(); } } result.search = relative.search; result.query = relative.query; // to support http.request if (result.pathname !== null || result.search !== null) { - result.path = (result.pathname ? result.pathname : "") + (result.search ? result.search : ""); + result.path = + (result.pathname ? result.pathname : "") + // force line break + (result.search ? result.search : ""); } result.href = result.format(); return result; @@ -712,8 +795,10 @@ Url.prototype.resolveObject = function (relative) { * then it must NOT get a trailing slash. */ var last = srcPath.slice(-1)[0]; - var hasTrailingSlash = - ((result.host || relative.host || srcPath.length > 1) && (last === "." || last === "..")) || last === ""; + // prettier-ignore + var hasTrailingSlash = ( + ((result.host || relative.host || srcPath.length > 1) && + (last === "." || last === "..")) || last === ""); /* * strip single dots, resolve double dots to parent dir @@ -762,12 +847,11 @@ Url.prototype.resolveObject = function (relative) { var authInHost = result.host && result.host.indexOf("@") > 0 ? result.host.split("@") : false; if (authInHost) { result.auth = authInHost.shift(); - result.hostname = authInHost.shift(); - result.host = result.hostname; + result.hostname = result.host = authInHost.shift(); } } - mustEndAbs = mustEndAbs || (result.host && srcPath.length); + mustEndAbs ||= result.host && srcPath.length; if (mustEndAbs && !isAbsolute) { srcPath.unshift(""); @@ -782,7 +866,9 @@ Url.prototype.resolveObject = function (relative) { // to support request.http if (result.pathname !== null || result.search !== null) { - result.path = (result.pathname ? result.pathname : "") + (result.search ? result.search : ""); + // prettier-ignore + result.path = (result.pathname ? result.pathname : "") + + (result.search ? result.search : ""); } result.auth = relative.auth || result.auth; result.slashes = result.slashes || relative.slashes; @@ -790,21 +876,69 @@ Url.prototype.resolveObject = function (relative) { return result; }; -Url.prototype.parseHost = function () { +Url.prototype.parseHost = function parseHost() { var host = this.host; var port = portPattern.exec(host); if (port) { port = port[0]; if (port !== ":") { - this.port = port.substr(1); + this.port = port.slice(1); } - host = host.substr(0, host.length - port.length); - } - if (host) { - this.hostname = host; + host = host.slice(0, host.length - port.length); } + if (host) this.hostname = host; }; +"".charCodeAt; +// function fileURLToPath(...args) { +// // Since we use WTF::URL::fileSystemPath directly in Bun.fileURLToPath, we don't get invalid windows +// // path checking. We patch this in to `node:url` for compatibility. Note that +// // this behavior is missing from WATWG URL. +// if (process.platform === "win32") { +// var url: string; +// if ($isObject(args[0]) && args[0] instanceof Url) { +// url = (args[0] as { href: string }).href; +// } else if (typeof args[0] === "string") { +// url = args[0]; +// } else { +// throw $ERR_INVALID_ARG_TYPE("url", ["string", "URL"], args[0]); +// } + +// for (var i = 0; i < url.length; i++) { +// if (url.charCodeAt(i) === Char.PERCENT && (i + 1) < url.length) { +// switch (url.charCodeAt(i + 1)) { +// break; +// } +// } +// } +// return Bun.fileURLToPath.$call(args); +// } + +/** + * Add new characters as needed from + * [here](https://github.com/nodejs/node/blob/main/lib/internal/constants.js). + * + * @note Do not move to another file, otherwise const enums will be imported as an object + * instead of being inlined. + */ +// prettier-ignore +const enum Char { + // non-alphabetic characters + AT = 64, // @ + COLON = 58, // : + BACKWARD_SLASH = 92, // \ + FORWARD_SLASH = 47, // / + HASH = 35, // # + QUESTION_MARK = 63, // ? + PERCENT = 37, // % + LEFT_SQUARE_BRACKET = 91, // [ + RIGHT_SQUARE_BRACKET = 93, // ] + + // whitespace + NO_BREAK_SPACE = 160, // \u00A0 + ZERO_WIDTH_NOBREAK_SPACE = 65279, // \uFEFF +} + export default { parse: urlParse, resolve: urlResolve, diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index d544fb953bd805..2da1afa169f98d 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -187,7 +187,7 @@ plugin({ // This is to test that it works when imported from a separate file import "../../third_party/svelte"; import "./module-plugins"; -import { render as svelteRender } from 'svelte/server'; +import { render as svelteRender } from "svelte/server"; describe("require", () => { it("SSRs `

Hello world!

` with Svelte", () => { @@ -476,7 +476,11 @@ describe("errors", () => { return new Response(result); }, }); - const { default: text } = await import(`http://${server.hostname}:${server.port}/hey.txt`); + const sleep = ms => new Promise(res => setTimeout(() => res("timeout"), ms)); + const text = await Promise.race([ + import(`http://${server.hostname}:${server.port}/hey.txt`).then(mod => mod.default) as Promise, + sleep(2_500), + ]); expect(text).toBe(result); }); }); diff --git a/test/js/bun/util/fileUrl.test.js b/test/js/bun/util/fileUrl.test.js index ecd6ad9c6f5c6b..8808a9422e42cc 100644 --- a/test/js/bun/util/fileUrl.test.js +++ b/test/js/bun/util/fileUrl.test.js @@ -9,9 +9,10 @@ describe("pathToFileURL", () => { }); describe("fileURLToPath", () => { + const absoluteErrorMessage = "File URL path must be an absolute"; it("should convert a file url to a path", () => { if (isWindows) { - expect(() => fileURLToPath("file:///path/to/file.js")).toThrow("File URL path must be absolute"); + expect(() => fileURLToPath("file:///path/to/file.js")).toThrow(absoluteErrorMessage); } else { expect(fileURLToPath("file:///path/to/file.js")).toBe("/path/to/file.js"); } @@ -19,7 +20,7 @@ describe("fileURLToPath", () => { it("should convert a URL to a path", () => { if (isWindows) { - expect(() => fileURLToPath(new URL("file:///path/to/file.js"))).toThrow("File URL path must be absolute"); + expect(() => fileURLToPath(new URL("file:///path/to/file.js"))).toThrow(absoluteErrorMessage); } else { expect(fileURLToPath(new URL("file:///path/to/file.js"))).toBe("/path/to/file.js"); } diff --git a/test/js/node/harness.ts b/test/js/node/harness.ts index c13b3a5afc4ce2..52bfb3c33a8039 100644 --- a/test/js/node/harness.ts +++ b/test/js/node/harness.ts @@ -315,12 +315,27 @@ if (normalized.includes("node/test/parallel")) { return (activeSuite = contexts[key] ??= createContext(key)); } - async function test(label: string | Function, fn?: Function | undefined) { + async function test( + label: string | Function, + optionsOrFn: Record | Function, + fn?: Function | undefined, + ) { + let options = optionsOrFn; + if (arguments.length === 2) { + assertNode.equal(typeof optionsOrFn, "function", "Second argument to test() must be a function."); + fn = optionsOrFn as Function; + options = {}; + } if (typeof fn !== "function" && typeof label === "function") { fn = label; label = fn.name; + options = {}; } + const ctx = getContext(); + const { skip } = options; + + if (skip) return; try { ctx.testStack.push(label as string); await fn(); diff --git a/test/js/node/test/parallel/needs-test/test-url-fileurltopath.js b/test/js/node/test/parallel/needs-test/test-url-fileurltopath.js new file mode 100644 index 00000000000000..3b08d06730b9ed --- /dev/null +++ b/test/js/node/test/parallel/needs-test/test-url-fileurltopath.js @@ -0,0 +1,177 @@ +'use strict'; +const { isWindows } = require('../../common'); + +const { test } = require('node:test'); +const assert = require('node:assert'); +const url = require('node:url'); + +test('invalid arguments', () => { + for (const arg of [null, undefined, 1, {}, true]) { + assert.throws(() => url.fileURLToPath(arg), { + code: 'ERR_INVALID_ARG_TYPE' + }); + } +}); + +test('input must be a file URL', () => { + assert.throws(() => url.fileURLToPath('https://a/b/c'), { + code: 'ERR_INVALID_URL_SCHEME' + }); +}); + +test('fileURLToPath with host', () => { + const withHost = new URL('file://host/a'); + + if (isWindows) { + assert.strictEqual(url.fileURLToPath(withHost), '\\\\host\\a'); + } else { + assert.throws(() => url.fileURLToPath(withHost), { + code: 'ERR_INVALID_FILE_URL_HOST' + }); + } +}); + +test('fileURLToPath with invalid path', () => { + if (isWindows) { + assert.throws(() => url.fileURLToPath('file:///C:/a%2F/'), { + code: 'ERR_INVALID_FILE_URL_PATH' + }); + assert.throws(() => url.fileURLToPath('file:///C:/a%5C/'), { + code: 'ERR_INVALID_FILE_URL_PATH' + }); + assert.throws(() => url.fileURLToPath('file:///?:/'), { + code: 'ERR_INVALID_FILE_URL_PATH' + }); + } else { + assert.throws(() => url.fileURLToPath('file:///a%2F/'), { + code: 'ERR_INVALID_FILE_URL_PATH' + }); + } +}); + +const windowsTestCases = [ + // Lowercase ascii alpha + { path: 'C:\\foo', fileURL: 'file:///C:/foo' }, + // Uppercase ascii alpha + { path: 'C:\\FOO', fileURL: 'file:///C:/FOO' }, + // dir + { path: 'C:\\dir\\foo', fileURL: 'file:///C:/dir/foo' }, + // trailing separator + { path: 'C:\\dir\\', fileURL: 'file:///C:/dir/' }, + // dot + { path: 'C:\\foo.mjs', fileURL: 'file:///C:/foo.mjs' }, + // space + { path: 'C:\\foo bar', fileURL: 'file:///C:/foo%20bar' }, + // question mark + { path: 'C:\\foo?bar', fileURL: 'file:///C:/foo%3Fbar' }, + // number sign + { path: 'C:\\foo#bar', fileURL: 'file:///C:/foo%23bar' }, + // ampersand + { path: 'C:\\foo&bar', fileURL: 'file:///C:/foo&bar' }, + // equals + { path: 'C:\\foo=bar', fileURL: 'file:///C:/foo=bar' }, + // colon + { path: 'C:\\foo:bar', fileURL: 'file:///C:/foo:bar' }, + // semicolon + { path: 'C:\\foo;bar', fileURL: 'file:///C:/foo;bar' }, + // percent + { path: 'C:\\foo%bar', fileURL: 'file:///C:/foo%25bar' }, + // backslash + { path: 'C:\\foo\\bar', fileURL: 'file:///C:/foo/bar' }, + // backspace + { path: 'C:\\foo\bbar', fileURL: 'file:///C:/foo%08bar' }, + // tab + { path: 'C:\\foo\tbar', fileURL: 'file:///C:/foo%09bar' }, + // newline + { path: 'C:\\foo\nbar', fileURL: 'file:///C:/foo%0Abar' }, + // carriage return + { path: 'C:\\foo\rbar', fileURL: 'file:///C:/foo%0Dbar' }, + // latin1 + { path: 'C:\\fóóbàr', fileURL: 'file:///C:/f%C3%B3%C3%B3b%C3%A0r' }, + // Euro sign (BMP code point) + { path: 'C:\\€', fileURL: 'file:///C:/%E2%82%AC' }, + // Rocket emoji (non-BMP code point) + { path: 'C:\\🚀', fileURL: 'file:///C:/%F0%9F%9A%80' }, + // UNC path (see https://docs.microsoft.com/en-us/archive/blogs/ie/file-uris-in-windows) + { path: '\\\\nas\\My Docs\\File.doc', fileURL: 'file://nas/My%20Docs/File.doc' }, +]; + +const posixTestCases = [ + // Lowercase ascii alpha + { path: '/foo', fileURL: 'file:///foo' }, + // Uppercase ascii alpha + { path: '/FOO', fileURL: 'file:///FOO' }, + // dir + { path: '/dir/foo', fileURL: 'file:///dir/foo' }, + // trailing separator + { path: '/dir/', fileURL: 'file:///dir/' }, + // dot + { path: '/foo.mjs', fileURL: 'file:///foo.mjs' }, + // space + { path: '/foo bar', fileURL: 'file:///foo%20bar' }, + // question mark + { path: '/foo?bar', fileURL: 'file:///foo%3Fbar' }, + // number sign + { path: '/foo#bar', fileURL: 'file:///foo%23bar' }, + // ampersand + { path: '/foo&bar', fileURL: 'file:///foo&bar' }, + // equals + { path: '/foo=bar', fileURL: 'file:///foo=bar' }, + // colon + { path: '/foo:bar', fileURL: 'file:///foo:bar' }, + // semicolon + { path: '/foo;bar', fileURL: 'file:///foo;bar' }, + // percent + { path: '/foo%bar', fileURL: 'file:///foo%25bar' }, + // backslash + { path: '/foo\\bar', fileURL: 'file:///foo%5Cbar' }, + // backspace + { path: '/foo\bbar', fileURL: 'file:///foo%08bar' }, + // tab + { path: '/foo\tbar', fileURL: 'file:///foo%09bar' }, + // newline + { path: '/foo\nbar', fileURL: 'file:///foo%0Abar' }, + // carriage return + { path: '/foo\rbar', fileURL: 'file:///foo%0Dbar' }, + // latin1 + { path: '/fóóbàr', fileURL: 'file:///f%C3%B3%C3%B3b%C3%A0r' }, + // Euro sign (BMP code point) + { path: '/€', fileURL: 'file:///%E2%82%AC' }, + // Rocket emoji (non-BMP code point) + { path: '/🚀', fileURL: 'file:///%F0%9F%9A%80' }, +]; + +test('fileURLToPath with windows path', { skip: !isWindows }, () => { + + for (const { path, fileURL } of windowsTestCases) { + const fromString = url.fileURLToPath(fileURL, { windows: true }); + assert.strictEqual(fromString, path); + const fromURL = url.fileURLToPath(new URL(fileURL), { windows: true }); + assert.strictEqual(fromURL, path); + } +}); + +test('fileURLToPath with posix path', { skip: isWindows }, () => { + for (const { path, fileURL } of posixTestCases) { + const fromString = url.fileURLToPath(fileURL, { windows: false }); + assert.strictEqual(fromString, path); + const fromURL = url.fileURLToPath(new URL(fileURL), { windows: false }); + assert.strictEqual(fromURL, path); + } +}); + +const defaultTestCases = isWindows ? windowsTestCases : posixTestCases; + +test('options is null', () => { + const whenNullActual = url.fileURLToPath(new URL(defaultTestCases[0].fileURL), null); + assert.strictEqual(whenNullActual, defaultTestCases[0].path); +}); + +test('defaultTestCases', () => { + for (const { path, fileURL } of defaultTestCases) { + const fromString = url.fileURLToPath(fileURL); + assert.strictEqual(fromString, path); + const fromURL = url.fileURLToPath(new URL(fileURL)); + assert.strictEqual(fromURL, path); + } +}); diff --git a/test/js/node/test/parallel/needs-test/test-url-format-invalid-input.js b/test/js/node/test/parallel/needs-test/test-url-format-invalid-input.js new file mode 100644 index 00000000000000..7ccb472a8d03a9 --- /dev/null +++ b/test/js/node/test/parallel/needs-test/test-url-format-invalid-input.js @@ -0,0 +1,32 @@ +'use strict'; + +require('../../common'); + +const assert = require('node:assert'); +const url = require('node:url'); +const { test } = require('node:test'); + +test('format invalid input', () => { + const throwsObjsAndReportTypes = [ + undefined, + null, + true, + false, + 0, + function() {}, + Symbol('foo'), + ]; + + for (const urlObject of throwsObjsAndReportTypes) { + console.log(urlObject) + assert.throws(function runFormat() { + url.format(urlObject); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + } + assert.strictEqual(url.format(''), ''); + assert.strictEqual(url.format({}), ''); +}); + diff --git a/test/js/node/test/parallel/test-url-is-url-internal.js b/test/js/node/test/parallel/test-url-is-url-internal.js new file mode 100644 index 00000000000000..0c05975fd71fda --- /dev/null +++ b/test/js/node/test/parallel/test-url-is-url-internal.js @@ -0,0 +1,24 @@ +/* + * NOTE (@DonIsaac) this file tests node internals, which Bun does not match. + * We aim for API compatability, but make no guarantees about internals. + */ + +// // Flags: --expose-internals +// 'use strict'; + +// require('../common'); + +// const { URL, parse } = require('node:url'); +// const assert = require('node:assert'); +// const { isURL } = require('internal/url'); +// const { test } = require('node:test'); + +// test('isURL', () => { +// assert.strictEqual(isURL(new URL('https://www.nodejs.org')), true); +// assert.strictEqual(isURL(parse('https://www.nodejs.org')), false); +// assert.strictEqual(isURL({ +// href: 'https://www.nodejs.org', +// protocol: 'https:', +// path: '/', +// }), false); +// }); diff --git a/test/js/node/test/parallel/test-url-parse-invalid-input.js b/test/js/node/test/parallel/test-url-parse-invalid-input.js new file mode 100644 index 00000000000000..a8ae4838af32c4 --- /dev/null +++ b/test/js/node/test/parallel/test-url-parse-invalid-input.js @@ -0,0 +1,107 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const url = require('url'); + +// https://github.com/joyent/node/issues/568 +[ + [undefined, 'undefined'], + [null, 'object'], + [true, 'boolean'], + [false, 'boolean'], + [0.0, 'number'], + [0, 'number'], + [[], 'object'], + [{}, 'object'], + [() => {}, 'function'], + [Symbol('foo'), 'symbol'], +].forEach(([val, type]) => { + assert.throws(() => { + url.parse(val); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "url" argument must be of type string.' + + common.invalidArgTypeHelper(val) + }); +}); + +assert.throws(() => { url.parse('http://%E0%A4%A@fail'); }, + (e) => { + // The error should be a URIError. + if (!(e instanceof URIError)) + return false; + + // The error should be from the JS engine and not from Node.js. + // JS engine errors do not have the `code` property. + return e.code === undefined; + }); + +assert.throws(() => { url.parse('http://[127.0.0.1\x00c8763]:8000/'); }, + { code: 'ERR_INVALID_URL', input: 'http://[127.0.0.1\x00c8763]:8000/' } +); + +if (common.hasIntl) { + // An array of Unicode code points whose Unicode NFKD contains a "bad + // character". + const badIDNA = (() => { + const BAD_CHARS = '#%/:?@[\\]^|'; + const out = []; + for (let i = 0x80; i < 0x110000; i++) { + const cp = String.fromCodePoint(i); + for (const badChar of BAD_CHARS) { + if (cp.normalize('NFKD').includes(badChar)) { + out.push(cp); + } + } + } + return out; + })(); + + // The generation logic above should at a minimum produce these two + // characters. + assert(badIDNA.includes('℀')); + assert(badIDNA.includes('@')); + + for (const badCodePoint of badIDNA) { + const badURL = `http://fail${badCodePoint}fail.com/`; + assert.throws(() => { url.parse(badURL); }, + (e) => e.code === 'ERR_INVALID_URL', + `parsing ${badURL}`); + } + + assert.throws(() => { url.parse('http://\u00AD/bad.com/'); }, + (e) => e.code === 'ERR_INVALID_URL', + 'parsing http://\u00AD/bad.com/'); +} + +{ + const badURLs = [ + 'https://evil.com:.example.com', + 'git+ssh://git@github.com:npm/npm', + ]; + badURLs.forEach((badURL) => { + common.spawnPromisified(process.execPath, ['-e', `url.parse(${JSON.stringify(badURL)})`]) + .then(common.mustCall(({ code, stdout, stderr }) => { + assert.strictEqual(code, 0); + assert.strictEqual(stdout, ''); + // NOTE: bun formats errors slightly differently from node, but we're + // printing the same deprecation message. + // assert.match(stderr, /\[DEP0170\] DeprecationWarning:/); + assert.match(stderr, /\DEP0170/); + assert.match(stderr, /\DeprecationWarning/); + })); + }); + + // Warning should only happen once per process. + common.expectWarning({ + DeprecationWarning: { + // eslint-disable-next-line @stylistic/js/max-len + DEP0169: '`url.parse()` behavior is not standardized and prone to errors that have security implications. Use the WHATWG URL API instead. CVEs are not issued for `url.parse()` vulnerabilities.', + DEP0170: `The URL ${badURLs[0]} is invalid. Future versions of Node.js will throw an error.`, + }, + }); + badURLs.forEach((badURL) => { + url.parse(badURL); + }); +} diff --git a/test/js/node/test/parallel/test-url-parse-query.js b/test/js/node/test/parallel/test-url-parse-query.js new file mode 100644 index 00000000000000..9cb21486c12641 --- /dev/null +++ b/test/js/node/test/parallel/test-url-parse-query.js @@ -0,0 +1,101 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const url = require('url'); + +function createWithNoPrototype(properties = []) { + const noProto = { __proto__: null }; + properties.forEach((property) => { + noProto[property.key] = property.value; + }); + return noProto; +} + +function check(actual, expected) { + // NOTE: Node creates a new object with no prototype when parsing queries. + // Their query parsing logic is written in JS. We re-use URLSearchParams + // from WebCore, which is spec-compliant with newer standards. + // assert.notStrictEqual(Object.getPrototypeOf(actual), Object.prototype); + assert.deepStrictEqual(Object.keys(actual).sort(), + Object.keys(expected).sort()); + Object.keys(expected).forEach(function(key) { + assert.deepStrictEqual( + actual[key], + expected[key], + `actual[${key}] !== expected[${key}]: ${actual[key]} !== ${expected[key]}` + ); + }); +} + +const parseTestsWithQueryString = { + '/foo/bar?baz=quux#frag': { + href: '/foo/bar?baz=quux#frag', + hash: '#frag', + search: '?baz=quux', + query: createWithNoPrototype([{ key: 'baz', value: 'quux' }]), + pathname: '/foo/bar', + path: '/foo/bar?baz=quux' + }, + 'http://example.com': { + href: 'http://example.com/', + protocol: 'http:', + slashes: true, + host: 'example.com', + hostname: 'example.com', + query: createWithNoPrototype(), + search: null, + pathname: '/', + path: '/' + }, + '/example': { + protocol: null, + slashes: null, + auth: undefined, + host: null, + port: null, + hostname: null, + hash: null, + search: null, + query: createWithNoPrototype(), + pathname: '/example', + path: '/example', + href: '/example' + }, + '/example?query=value': { + protocol: null, + slashes: null, + auth: undefined, + host: null, + port: null, + hostname: null, + hash: null, + search: '?query=value', + query: createWithNoPrototype([{ key: 'query', value: 'value' }]), + pathname: '/example', + path: '/example?query=value', + href: '/example?query=value' + } +}; +for (const u in parseTestsWithQueryString) { + const actual = url.parse(u, true); + const expected = Object.assign(new url.Url(), parseTestsWithQueryString[u]); + for (const i in actual) { + if (actual[i] === null && expected[i] === undefined) { + expected[i] = null; + } + } + + const properties = Object.keys(actual).sort(); + assert.deepStrictEqual(properties, Object.keys(expected).sort()); + properties.forEach((property) => { + if (property === 'query') { + check(actual[property], expected[property]); + } else { + assert.deepStrictEqual( + actual[property], + expected[property], + `${u}\n\nactual['${property}'] !== expected['${property}']: ${actual[property]} !== ${expected[property]}` + ); + } + }); +} diff --git a/test/js/node/test/parallel/test-url-relative.js b/test/js/node/test/parallel/test-url-relative.js new file mode 100644 index 00000000000000..2751ec3b512584 --- /dev/null +++ b/test/js/node/test/parallel/test-url-relative.js @@ -0,0 +1,443 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const inspect = require('util').inspect; +const url = require('url'); + +// When source is false +assert.strictEqual(url.resolveObject('', 'foo'), 'foo'); + +// [from, path, expected] +const relativeTests = [ + ['/foo/bar/baz', 'quux', '/foo/bar/quux'], + ['/foo/bar/baz', 'quux/asdf', '/foo/bar/quux/asdf'], + ['/foo/bar/baz', 'quux/baz', '/foo/bar/quux/baz'], + ['/foo/bar/baz', '../quux/baz', '/foo/quux/baz'], + ['/foo/bar/baz', '/bar', '/bar'], + ['/foo/bar/baz/', 'quux', '/foo/bar/baz/quux'], + ['/foo/bar/baz/', 'quux/baz', '/foo/bar/baz/quux/baz'], + ['/foo/bar/baz', '../../../../../../../../quux/baz', '/quux/baz'], + ['/foo/bar/baz', '../../../../../../../quux/baz', '/quux/baz'], + ['/foo', '.', '/'], + ['/foo', '..', '/'], + ['/foo/', '.', '/foo/'], + ['/foo/', '..', '/'], + ['/foo/bar', '.', '/foo/'], + ['/foo/bar', '..', '/'], + ['/foo/bar/', '.', '/foo/bar/'], + ['/foo/bar/', '..', '/foo/'], + ['foo/bar', '../../../baz', '../../baz'], + ['foo/bar/', '../../../baz', '../baz'], + ['http://example.com/b//c//d;p?q#blarg', 'https:#hash2', 'https:///#hash2'], + ['http://example.com/b//c//d;p?q#blarg', + 'https:/p/a/t/h?s#hash2', + 'https://p/a/t/h?s#hash2'], + ['http://example.com/b//c//d;p?q#blarg', + 'https://u:p@h.com/p/a/t/h?s#hash2', + 'https://u:p@h.com/p/a/t/h?s#hash2'], + ['http://example.com/b//c//d;p?q#blarg', + 'https:/a/b/c/d', + 'https://a/b/c/d'], + ['http://example.com/b//c//d;p?q#blarg', + 'http:#hash2', + 'http://example.com/b//c//d;p?q#hash2'], + ['http://example.com/b//c//d;p?q#blarg', + 'http:/p/a/t/h?s#hash2', + 'http://example.com/p/a/t/h?s#hash2'], + ['http://example.com/b//c//d;p?q#blarg', + 'http://u:p@h.com/p/a/t/h?s#hash2', + 'http://u:p@h.com/p/a/t/h?s#hash2'], + ['http://example.com/b//c//d;p?q#blarg', + 'http:/a/b/c/d', + 'http://example.com/a/b/c/d'], + ['/foo/bar/baz', '/../etc/passwd', '/etc/passwd'], + ['http://localhost', 'file:///Users/foo', 'file:///Users/foo'], + ['http://localhost', 'file://foo/Users', 'file://foo/Users'], + ['https://registry.npmjs.org', '@foo/bar', 'https://registry.npmjs.org/@foo/bar'], +]; +for (let i = 0; i < relativeTests.length; i++) { + const relativeTest = relativeTests[i]; + + const a = url.resolve(relativeTest[0], relativeTest[1]); + const e = relativeTest[2]; + assert.strictEqual(a, e, + `resolve(${relativeTest[0]}, ${relativeTest[1]})` + + ` == ${e}\n actual=${a}`); +} + +// +// Tests below taken from Chiron +// http://code.google.com/p/chironjs/source/browse/trunk/src/test/http/url.js +// +// Copyright (c) 2002-2008 Kris Kowal +// used with permission under MIT License +// +// Changes marked with @isaacs + +const bases = [ + 'http://a/b/c/d;p?q', + 'http://a/b/c/d;p?q=1/2', + 'http://a/b/c/d;p=1/2?q', + 'fred:///s//a/b/c', + 'http:///s//a/b/c', +]; + +// [to, from, result] +const relativeTests2 = [ + // http://lists.w3.org/Archives/Public/uri/2004Feb/0114.html + ['../c', 'foo:a/b', 'foo:c'], + ['foo:.', 'foo:a', 'foo:'], + ['/foo/../../../bar', 'zz:abc', 'zz:/bar'], + ['/foo/../bar', 'zz:abc', 'zz:/bar'], + // @isaacs Disagree. Not how web browsers resolve this. + ['foo/../../../bar', 'zz:abc', 'zz:bar'], + // ['foo/../../../bar', 'zz:abc', 'zz:../../bar'], // @isaacs Added + ['foo/../bar', 'zz:abc', 'zz:bar'], + ['zz:.', 'zz:abc', 'zz:'], + ['/.', bases[0], 'http://a/'], + ['/.foo', bases[0], 'http://a/.foo'], + ['.foo', bases[0], 'http://a/b/c/.foo'], + + // http://gbiv.com/protocols/uri/test/rel_examples1.html + // examples from RFC 2396 + ['g:h', bases[0], 'g:h'], + ['g', bases[0], 'http://a/b/c/g'], + ['./g', bases[0], 'http://a/b/c/g'], + ['g/', bases[0], 'http://a/b/c/g/'], + ['/g', bases[0], 'http://a/g'], + ['//g', bases[0], 'http://g/'], + // Changed with RFC 2396bis + // ('?y', bases[0], 'http://a/b/c/d;p?y'], + ['?y', bases[0], 'http://a/b/c/d;p?y'], + ['g?y', bases[0], 'http://a/b/c/g?y'], + // Changed with RFC 2396bis + // ('#s', bases[0], CURRENT_DOC_URI + '#s'], + ['#s', bases[0], 'http://a/b/c/d;p?q#s'], + ['g#s', bases[0], 'http://a/b/c/g#s'], + ['g?y#s', bases[0], 'http://a/b/c/g?y#s'], + [';x', bases[0], 'http://a/b/c/;x'], + ['g;x', bases[0], 'http://a/b/c/g;x'], + ['g;x?y#s', bases[0], 'http://a/b/c/g;x?y#s'], + // Changed with RFC 2396bis + // ('', bases[0], CURRENT_DOC_URI], + ['', bases[0], 'http://a/b/c/d;p?q'], + ['.', bases[0], 'http://a/b/c/'], + ['./', bases[0], 'http://a/b/c/'], + ['..', bases[0], 'http://a/b/'], + ['../', bases[0], 'http://a/b/'], + ['../g', bases[0], 'http://a/b/g'], + ['../..', bases[0], 'http://a/'], + ['../../', bases[0], 'http://a/'], + ['../../g', bases[0], 'http://a/g'], + ['../../../g', bases[0], ('http://a/../g', 'http://a/g')], + ['../../../../g', bases[0], ('http://a/../../g', 'http://a/g')], + // Changed with RFC 2396bis + // ('/./g', bases[0], 'http://a/./g'], + ['/./g', bases[0], 'http://a/g'], + // Changed with RFC 2396bis + // ('/../g', bases[0], 'http://a/../g'], + ['/../g', bases[0], 'http://a/g'], + ['g.', bases[0], 'http://a/b/c/g.'], + ['.g', bases[0], 'http://a/b/c/.g'], + ['g..', bases[0], 'http://a/b/c/g..'], + ['..g', bases[0], 'http://a/b/c/..g'], + ['./../g', bases[0], 'http://a/b/g'], + ['./g/.', bases[0], 'http://a/b/c/g/'], + ['g/./h', bases[0], 'http://a/b/c/g/h'], + ['g/../h', bases[0], 'http://a/b/c/h'], + ['g;x=1/./y', bases[0], 'http://a/b/c/g;x=1/y'], + ['g;x=1/../y', bases[0], 'http://a/b/c/y'], + ['g?y/./x', bases[0], 'http://a/b/c/g?y/./x'], + ['g?y/../x', bases[0], 'http://a/b/c/g?y/../x'], + ['g#s/./x', bases[0], 'http://a/b/c/g#s/./x'], + ['g#s/../x', bases[0], 'http://a/b/c/g#s/../x'], + ['http:g', bases[0], ('http:g', 'http://a/b/c/g')], + ['http:', bases[0], ('http:', bases[0])], + // Not sure where this one originated + ['/a/b/c/./../../g', bases[0], 'http://a/a/g'], + + // http://gbiv.com/protocols/uri/test/rel_examples2.html + // slashes in base URI's query args + ['g', bases[1], 'http://a/b/c/g'], + ['./g', bases[1], 'http://a/b/c/g'], + ['g/', bases[1], 'http://a/b/c/g/'], + ['/g', bases[1], 'http://a/g'], + ['//g', bases[1], 'http://g/'], + // Changed in RFC 2396bis + // ('?y', bases[1], 'http://a/b/c/?y'], + ['?y', bases[1], 'http://a/b/c/d;p?y'], + ['g?y', bases[1], 'http://a/b/c/g?y'], + ['g?y/./x', bases[1], 'http://a/b/c/g?y/./x'], + ['g?y/../x', bases[1], 'http://a/b/c/g?y/../x'], + ['g#s', bases[1], 'http://a/b/c/g#s'], + ['g#s/./x', bases[1], 'http://a/b/c/g#s/./x'], + ['g#s/../x', bases[1], 'http://a/b/c/g#s/../x'], + ['./', bases[1], 'http://a/b/c/'], + ['../', bases[1], 'http://a/b/'], + ['../g', bases[1], 'http://a/b/g'], + ['../../', bases[1], 'http://a/'], + ['../../g', bases[1], 'http://a/g'], + + // http://gbiv.com/protocols/uri/test/rel_examples3.html + // slashes in path params + // all of these changed in RFC 2396bis + ['g', bases[2], 'http://a/b/c/d;p=1/g'], + ['./g', bases[2], 'http://a/b/c/d;p=1/g'], + ['g/', bases[2], 'http://a/b/c/d;p=1/g/'], + ['g?y', bases[2], 'http://a/b/c/d;p=1/g?y'], + [';x', bases[2], 'http://a/b/c/d;p=1/;x'], + ['g;x', bases[2], 'http://a/b/c/d;p=1/g;x'], + ['g;x=1/./y', bases[2], 'http://a/b/c/d;p=1/g;x=1/y'], + ['g;x=1/../y', bases[2], 'http://a/b/c/d;p=1/y'], + ['./', bases[2], 'http://a/b/c/d;p=1/'], + ['../', bases[2], 'http://a/b/c/'], + ['../g', bases[2], 'http://a/b/c/g'], + ['../../', bases[2], 'http://a/b/'], + ['../../g', bases[2], 'http://a/b/g'], + + // http://gbiv.com/protocols/uri/test/rel_examples4.html + // double and triple slash, unknown scheme + ['g:h', bases[3], 'g:h'], + ['g', bases[3], 'fred:///s//a/b/g'], + ['./g', bases[3], 'fred:///s//a/b/g'], + ['g/', bases[3], 'fred:///s//a/b/g/'], + ['/g', bases[3], 'fred:///g'], // May change to fred:///s//a/g + ['//g', bases[3], 'fred://g'], // May change to fred:///s//g + ['//g/x', bases[3], 'fred://g/x'], // May change to fred:///s//g/x + ['///g', bases[3], 'fred:///g'], + ['./', bases[3], 'fred:///s//a/b/'], + ['../', bases[3], 'fred:///s//a/'], + ['../g', bases[3], 'fred:///s//a/g'], + + ['../../', bases[3], 'fred:///s//'], + ['../../g', bases[3], 'fred:///s//g'], + ['../../../g', bases[3], 'fred:///s/g'], + // May change to fred:///s//a/../../../g + ['../../../../g', bases[3], 'fred:///g'], + + // http://gbiv.com/protocols/uri/test/rel_examples5.html + // double and triple slash, well-known scheme + ['g:h', bases[4], 'g:h'], + ['g', bases[4], 'http:///s//a/b/g'], + ['./g', bases[4], 'http:///s//a/b/g'], + ['g/', bases[4], 'http:///s//a/b/g/'], + ['/g', bases[4], 'http:///g'], // May change to http:///s//a/g + ['//g', bases[4], 'http://g/'], // May change to http:///s//g + ['//g/x', bases[4], 'http://g/x'], // May change to http:///s//g/x + ['///g', bases[4], 'http:///g'], + ['./', bases[4], 'http:///s//a/b/'], + ['../', bases[4], 'http:///s//a/'], + ['../g', bases[4], 'http:///s//a/g'], + ['../../', bases[4], 'http:///s//'], + ['../../g', bases[4], 'http:///s//g'], + // May change to http:///s//a/../../g + ['../../../g', bases[4], 'http:///s/g'], + // May change to http:///s//a/../../../g + ['../../../../g', bases[4], 'http:///g'], + + // From Dan Connelly's tests in http://www.w3.org/2000/10/swap/uripath.py + ['bar:abc', 'foo:xyz', 'bar:abc'], + ['../abc', 'http://example/x/y/z', 'http://example/x/abc'], + ['http://example/x/abc', 'http://example2/x/y/z', 'http://example/x/abc'], + ['../r', 'http://ex/x/y/z', 'http://ex/x/r'], + ['q/r', 'http://ex/x/y', 'http://ex/x/q/r'], + ['q/r#s', 'http://ex/x/y', 'http://ex/x/q/r#s'], + ['q/r#s/t', 'http://ex/x/y', 'http://ex/x/q/r#s/t'], + ['ftp://ex/x/q/r', 'http://ex/x/y', 'ftp://ex/x/q/r'], + ['', 'http://ex/x/y', 'http://ex/x/y'], + ['', 'http://ex/x/y/', 'http://ex/x/y/'], + ['', 'http://ex/x/y/pdq', 'http://ex/x/y/pdq'], + ['z/', 'http://ex/x/y/', 'http://ex/x/y/z/'], + ['#Animal', + 'file:/swap/test/animal.rdf', + 'file:/swap/test/animal.rdf#Animal'], + ['../abc', 'file:/e/x/y/z', 'file:/e/x/abc'], + ['/example/x/abc', 'file:/example2/x/y/z', 'file:/example/x/abc'], + ['../r', 'file:/ex/x/y/z', 'file:/ex/x/r'], + ['/r', 'file:/ex/x/y/z', 'file:/r'], + ['q/r', 'file:/ex/x/y', 'file:/ex/x/q/r'], + ['q/r#s', 'file:/ex/x/y', 'file:/ex/x/q/r#s'], + ['q/r#', 'file:/ex/x/y', 'file:/ex/x/q/r#'], + ['q/r#s/t', 'file:/ex/x/y', 'file:/ex/x/q/r#s/t'], + ['ftp://ex/x/q/r', 'file:/ex/x/y', 'ftp://ex/x/q/r'], + ['', 'file:/ex/x/y', 'file:/ex/x/y'], + ['', 'file:/ex/x/y/', 'file:/ex/x/y/'], + ['', 'file:/ex/x/y/pdq', 'file:/ex/x/y/pdq'], + ['z/', 'file:/ex/x/y/', 'file:/ex/x/y/z/'], + ['file://meetings.example.com/cal#m1', + 'file:/devel/WWW/2000/10/swap/test/reluri-1.n3', + 'file://meetings.example.com/cal#m1'], + ['file://meetings.example.com/cal#m1', + 'file:/home/connolly/w3ccvs/WWW/2000/10/swap/test/reluri-1.n3', + 'file://meetings.example.com/cal#m1'], + ['./#blort', 'file:/some/dir/foo', 'file:/some/dir/#blort'], + ['./#', 'file:/some/dir/foo', 'file:/some/dir/#'], + // Ryan Lee + ['./', 'http://example/x/abc.efg', 'http://example/x/'], + + + // Graham Klyne's tests + // http://www.ninebynine.org/Software/HaskellUtils/Network/UriTest.xls + // 01-31 are from Connelly's cases + + // 32-49 + ['./q:r', 'http://ex/x/y', 'http://ex/x/q:r'], + ['./p=q:r', 'http://ex/x/y', 'http://ex/x/p=q:r'], + ['?pp/rr', 'http://ex/x/y?pp/qq', 'http://ex/x/y?pp/rr'], + ['y/z', 'http://ex/x/y?pp/qq', 'http://ex/x/y/z'], + ['local/qual@domain.org#frag', + 'mailto:local', + 'mailto:local/qual@domain.org#frag'], + ['more/qual2@domain2.org#frag', + 'mailto:local/qual1@domain1.org', + 'mailto:local/more/qual2@domain2.org#frag'], + ['y?q', 'http://ex/x/y?q', 'http://ex/x/y?q'], + ['/x/y?q', 'http://ex?p', 'http://ex/x/y?q'], + ['c/d', 'foo:a/b', 'foo:a/c/d'], + ['/c/d', 'foo:a/b', 'foo:/c/d'], + ['', 'foo:a/b?c#d', 'foo:a/b?c'], + ['b/c', 'foo:a', 'foo:b/c'], + ['../b/c', 'foo:/a/y/z', 'foo:/a/b/c'], + ['./b/c', 'foo:a', 'foo:b/c'], + ['/./b/c', 'foo:a', 'foo:/b/c'], + ['../../d', 'foo://a//b/c', 'foo://a/d'], + ['.', 'foo:a', 'foo:'], + ['..', 'foo:a', 'foo:'], + + // 50-57[cf. TimBL comments -- + // http://lists.w3.org/Archives/Public/uri/2003Feb/0028.html, + // http://lists.w3.org/Archives/Public/uri/2003Jan/0008.html) + ['abc', 'http://example/x/y%2Fz', 'http://example/x/abc'], + ['../../x%2Fabc', 'http://example/a/x/y/z', 'http://example/a/x%2Fabc'], + ['../x%2Fabc', 'http://example/a/x/y%2Fz', 'http://example/a/x%2Fabc'], + ['abc', 'http://example/x%2Fy/z', 'http://example/x%2Fy/abc'], + ['q%3Ar', 'http://ex/x/y', 'http://ex/x/q%3Ar'], + ['/x%2Fabc', 'http://example/x/y%2Fz', 'http://example/x%2Fabc'], + ['/x%2Fabc', 'http://example/x/y/z', 'http://example/x%2Fabc'], + ['/x%2Fabc', 'http://example/x/y%2Fz', 'http://example/x%2Fabc'], + + // 70-77 + ['local2@domain2', 'mailto:local1@domain1?query1', 'mailto:local2@domain2'], + ['local2@domain2?query2', + 'mailto:local1@domain1', + 'mailto:local2@domain2?query2'], + ['local2@domain2?query2', + 'mailto:local1@domain1?query1', + 'mailto:local2@domain2?query2'], + ['?query2', 'mailto:local@domain?query1', 'mailto:local@domain?query2'], + ['local@domain?query2', 'mailto:?query1', 'mailto:local@domain?query2'], + ['?query2', 'mailto:local@domain?query1', 'mailto:local@domain?query2'], + ['http://example/a/b?c/../d', 'foo:bar', 'http://example/a/b?c/../d'], + ['http://example/a/b#c/../d', 'foo:bar', 'http://example/a/b#c/../d'], + + // 82-88 + // @isaacs Disagree. Not how browsers do it. + // ['http:this', 'http://example.org/base/uri', 'http:this'], + // @isaacs Added + ['http:this', 'http://example.org/base/uri', 'http://example.org/base/this'], + ['http:this', 'http:base', 'http:this'], + ['.//g', 'f:/a', 'f://g'], + ['b/c//d/e', 'f://example.org/base/a', 'f://example.org/base/b/c//d/e'], + ['m2@example.ord/c2@example.org', + 'mid:m@example.ord/c@example.org', + 'mid:m@example.ord/m2@example.ord/c2@example.org'], + ['mini1.xml', + 'file:///C:/DEV/Haskell/lib/HXmlToolbox-3.01/examples/', + 'file:///C:/DEV/Haskell/lib/HXmlToolbox-3.01/examples/mini1.xml'], + ['../b/c', 'foo:a/y/z', 'foo:a/b/c'], + + // changing auth + ['http://diff:auth@www.example.com', + 'http://asdf:qwer@www.example.com', + 'http://diff:auth@www.example.com/'], + + // changing port + ['https://example.com:81/', + 'https://example.com:82/', + 'https://example.com:81/'], + + // https://github.com/nodejs/node/issues/1435 + ['https://another.host.com/', + 'https://user:password@example.org/', + 'https://another.host.com/'], + ['//another.host.com/', + 'https://user:password@example.org/', + 'https://another.host.com/'], + ['http://another.host.com/', + 'https://user:password@example.org/', + 'http://another.host.com/'], + ['mailto:another.host.com', + 'mailto:user@example.org', + 'mailto:another.host.com'], + ['https://example.com/foo', + 'https://user:password@example.com', + 'https://user:password@example.com/foo'], + + // No path at all + ['#hash1', '#hash2', '#hash1'], +]; +for (let i = 0; i < relativeTests2.length; i++) { + const relativeTest = relativeTests2[i]; + + const a = url.resolve(relativeTest[1], relativeTest[0]); + const e = url.format(relativeTest[2]); + assert.strictEqual(a, e, + `resolve(${relativeTest[0]}, ${relativeTest[1]})` + + ` == ${e}\n actual=${a}`); +} + +// If format and parse are inverse operations then +// resolveObject(parse(x), y) == parse(resolve(x, y)) + +// format: [from, path, expected] +for (let i = 0; i < relativeTests.length; i++) { + const relativeTest = relativeTests[i]; + + let actual = url.resolveObject(url.parse(relativeTest[0]), relativeTest[1]); + let expected = url.parse(relativeTest[2]); + + + assert.deepStrictEqual(actual, expected); + + expected = relativeTest[2]; + actual = url.format(actual); + + assert.strictEqual(actual, expected, + `format(${actual}) == ${expected}\n` + + `actual: ${actual}`); + +} + +// format: [to, from, result] +// the test: ['.//g', 'f:/a', 'f://g'] is a fundamental problem +// url.parse('f:/a') does not have a host +// url.resolve('f:/a', './/g') does not have a host because you have moved +// down to the g directory. i.e. f: //g, however when this url is parsed +// f:// will indicate that the host is g which is not the case. +// it is unclear to me how to keep this information from being lost +// it may be that a pathname of ////g should collapse to /g but this seems +// to be a lot of work for an edge case. Right now I remove the test +if (relativeTests2[181][0] === './/g' && + relativeTests2[181][1] === 'f:/a' && + relativeTests2[181][2] === 'f://g') { + relativeTests2.splice(181, 1); +} +for (let i = 0; i < relativeTests2.length; i++) { + const relativeTest = relativeTests2[i]; + + let actual = url.resolveObject(url.parse(relativeTest[1]), relativeTest[0]); + let expected = url.parse(relativeTest[2]); + + assert.deepStrictEqual( + actual, + expected, + `expected ${inspect(expected)} but got ${inspect(actual)}` + ); + + expected = url.format(relativeTest[2]); + actual = url.format(actual); + + assert.strictEqual(actual, expected, + `format(${relativeTest[1]}) == ${expected}\n` + + `actual: ${actual}`); +} diff --git a/test/js/node/url/url-parse-ipv6.test.ts b/test/js/node/url/url-parse-ipv6.test.ts new file mode 100644 index 00000000000000..08de7bce99ebf6 --- /dev/null +++ b/test/js/node/url/url-parse-ipv6.test.ts @@ -0,0 +1,190 @@ +// prettier-ignore +import url from "node:url"; +import { describe, beforeAll, it, expect } from "bun:test"; + +// url.parse is deprecated. +process.emitWarning = () => {}; + +describe("Invalid IPv6 addresses", () => { + it.each([ + "https://[::1", + "https://[:::1]", + "https://[\n::1]", + "http://[::banana]", + ])("Invalid hostnames - parsing '%s' fails", input => { + expect(() => url.parse(input)).toThrowError(TypeError); + }); + + it.each([ + "https://[::1]::", + "https://[::1]:foo" + ])("Invalid ports - parsing '%s' fails", input => { + expect(() => url.parse(input)).toThrowError(TypeError); + }); +}); // + +describe("Valid spot checks", () => { + it.each([ + // ports + ["http://[::1]:", { host: "[::1]", hostname: "::1", port: null, path: "/", href: "http://[::1]/" }], // trailing colons are ignored + ["http://[::1]:1", { host: "[::1]", hostname: "::1", port: "1", path: "/", href: "http://[::1]/" }], + + // unicast + ["http://[::0]", { host: "[::0]", path: "/" }], + ["http://[::f]", { host: "[::f]", path: "/" }], + ["http://[::F]", { host: "[::F]", path: "/" }], + // these are technically invalid unicast addresses but url.parse allows them + ["http://[::7]", { host: "[::7]", path: "/" }], + // ["http://[::z]", { host: "[::7]", path: "/" }], + // ["http://[::😩]", { host: "[::😩]", path: "/" }], + + // full form-ish + ["https://[::1:2:3:4:5]", { host: "[::1:2:3:4:5]", path: "/" }], + ["[0:0:0:1:2:3:4:5]", { host: "[0:0:0:1:2:3:4:5]", path: "/" }], + ])("Parsing '%s' succeeds", (input, expected) => { + expect(url.parse(input)).toMatchObject(expect.objectContaining(expected)); + }); +}); // + +// checks on all properties +describe.each([ + [ + "[::1]", // w/o a protocol, it's treated as a path + { + protocol: null, + slashes: null, + auth: null, + host: null, + port: null, + hostname: null, + hash: null, + search: null, + query: null, + pathname: "[::1]", + path: "[::1]", + href: "[::1]", + }, + ], + [ + "https://[::1]", + { + protocol: "https:", + slashes: true, + auth: null, + host: "[::1]", + port: null, + hostname: "::1", + hash: null, + search: null, + query: null, + pathname: "/", + path: "/", + href: "https://[::1]/", + }, + ], + [ + "http://user@[::1]:3000/foo/bar#baz?a=hi&b=1&c=%20", + { + protocol: "http:", + slashes: true, + auth: "user", + host: "[::1]:3000", + port: "3000", + hostname: "::1", + hash: "#baz?a=hi&b=1&c=%20", + search: null, + query: null, + pathname: "/foo/bar", + path: "/foo/bar", + href: "http://user@[::1]:3000/foo/bar#baz?a=hi&b=1&c=%20", + }, + ], + [ + "http://user@[::1]:80/foo/bar?a=hi&b=1&c=%20", + { + protocol: "http:", + slashes: true, + auth: "user", + host: "[::1]:80", + port: "80", + hostname: "::1", + hash: null, + search: "?a=hi&b=1&c=%20", + query: "a=hi&b=1&c=%20", + pathname: "/foo/bar", + path: "/foo/bar?a=hi&b=1&c=%20", + href: "http://user@[::1]:80/foo/bar?a=hi&b=1&c=%20", + }, + ], + /* + [ + // 7 bytes instead of 8 + "http://[0:0:1:2:3:4:5]/foo?bar#bar", + { + protocol: "http:", + slashes: true, + auth: null, + host: "[0:0:1:2:3:4:5]", + port: null, + hostname: "0:0:1:2:3:4:5", + hash: "#bar", + search: "?bar", + query: "bar", + pathname: "/foo", + path: "/foo?bar", + href: "http://[0:0:1:2:3:4:5]/foo?bar#bar", + }, + ], + */ + [ + "file://[::1]", + { + protocol: "file:", + slashes: true, + auth: null, + host: "[::1]", + port: null, + hostname: "::1", + hash: null, + search: null, + query: null, + pathname: "/", + path: "/", + href: "file://[::1]/", + }, + ], +])("Valid", (input, expected) => { + describe(`url.parse("${input}")`, () => { + let parsed: url.UrlWithStringQuery; + + beforeAll(() => { + parsed = url.parse(input); + }); + + it("parses to the expected object", () => { + expect(parsed).toMatchObject(expected); + }); + + it("is a Url, not a URL", () => { + expect(parsed).not.toBeInstanceOf(url.URL); + expect(parsed).not.toBeInstanceOf(globalThis.URL); + }); + }); // + + describe(`url.parse("${input}", true)`, () => { + let parsed: url.UrlWithParsedQuery; + + beforeAll(() => { + parsed = url.parse(input, true); + }); + + it("parses to the expected object", () => { + const { query, ...rest } = expected; + expect(parsed).toMatchObject(expect.objectContaining(rest)); + }); + + it("parses the query", () => { + expect(parsed.query).not.toBeInstanceOf(String); + }); + }); // +}); //