From b97561f3f8ef7c63dddffe87c26d099f80f5c787 Mon Sep 17 00:00:00 2001 From: pfg Date: Fri, 25 Apr 2025 23:40:09 -0700 Subject: [PATCH] mime api (test-mime-api, test-mime-whatwg) (#19284) --- src/bun.js/bindings/ErrorCode.cpp | 31 + src/bun.js/bindings/ErrorCode.h | 1 + src/bun.js/bindings/ErrorCode.ts | 1 + src/bun.js/bindings/ZigGlobalObject.cpp | 14 + src/bun.js/bindings/ZigGlobalObject.h | 4 + .../bindings/webcore/DOMClientIsoSubspaces.h | 2 + src/bun.js/bindings/webcore/DOMIsoSubspaces.h | 2 + .../bindings/webcore/JSMIMEBindings.cpp | 24 + src/bun.js/bindings/webcore/JSMIMEBindings.h | 14 + src/bun.js/bindings/webcore/JSMIMEParams.cpp | 721 ++++++++++++++++++ src/bun.js/bindings/webcore/JSMIMEParams.h | 94 +++ src/bun.js/bindings/webcore/JSMIMEType.cpp | 617 +++++++++++++++ src/bun.js/bindings/webcore/JSMIMEType.h | 100 +++ src/highway.zig | 4 +- src/js/builtins.d.ts | 1 + src/js/internal/util/mime.ts | 6 + src/js/node/util.ts | 5 +- test/js/node/test/parallel/test-mime-api.js | 180 +++++ .../js/node/test/parallel/test-mime-whatwg.js | 23 + test/js/node/util/exact/mime-test.js | 315 ++++++++ test/js/node/util/mime-api.test.ts | 409 ++++++++++ 21 files changed, 2564 insertions(+), 4 deletions(-) create mode 100644 src/bun.js/bindings/webcore/JSMIMEBindings.cpp create mode 100644 src/bun.js/bindings/webcore/JSMIMEBindings.h create mode 100644 src/bun.js/bindings/webcore/JSMIMEParams.cpp create mode 100644 src/bun.js/bindings/webcore/JSMIMEParams.h create mode 100644 src/bun.js/bindings/webcore/JSMIMEType.cpp create mode 100644 src/bun.js/bindings/webcore/JSMIMEType.h create mode 100644 src/js/internal/util/mime.ts create mode 100644 test/js/node/test/parallel/test-mime-api.js create mode 100644 test/js/node/test/parallel/test-mime-whatwg.js create mode 100644 test/js/node/util/exact/mime-test.js create mode 100644 test/js/node/util/mime-api.test.ts diff --git a/src/bun.js/bindings/ErrorCode.cpp b/src/bun.js/bindings/ErrorCode.cpp index 02f7bbf825..03b3ae1760 100644 --- a/src/bun.js/bindings/ErrorCode.cpp +++ b/src/bun.js/bindings/ErrorCode.cpp @@ -1384,6 +1384,24 @@ JSC::EncodedJSValue MISSING_OPTION(JSC::ThrowScope& scope, JSC::JSGlobalObject* return {}; } +JSC::EncodedJSValue INVALID_MIME_SYNTAX(JSC::ThrowScope& scope, JSC::JSGlobalObject* globalObject, const String& part, const String& input, int position) +{ + WTF::StringBuilder builder; + builder.append("The MIME syntax for a "_s); + builder.append(part); + builder.append(" in "_s); + builder.append(input); + + builder.append(" is invalid"_s); + if (position != -1) { + builder.append(" at "_s); + builder.append(String::number(position)); + } + + scope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_INVALID_MIME_SYNTAX, builder.toString())); + return {}; +} + EncodedJSValue CLOSED_MESSAGE_PORT(ThrowScope& scope, JSGlobalObject* globalObject) { scope.throwException(globalObject, createError(globalObject, ErrorCode::ERR_CLOSED_MESSAGE_PORT, "Cannot send data on closed MessagePort"_s)); @@ -1606,6 +1624,19 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSValue::encode(createError(globalObject, ErrorCode::ERR_INVALID_IP_ADDRESS, builder.toString())); } + case Bun::ErrorCode::ERR_INVALID_MIME_SYNTAX: { + auto arg0 = callFrame->argument(1); + auto str0 = arg0.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto arg1 = callFrame->argument(2); + auto str1 = arg1.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto arg2 = callFrame->argument(3); + auto str2 = arg2.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return ERR::INVALID_MIME_SYNTAX(scope, globalObject, str0, str1, str2); + } + case Bun::ErrorCode::ERR_INVALID_ADDRESS_FAMILY: { auto arg0 = callFrame->argument(1); auto str0 = arg0.toWTFString(globalObject); diff --git a/src/bun.js/bindings/ErrorCode.h b/src/bun.js/bindings/ErrorCode.h index c3647d6132..7eb7588d3a 100644 --- a/src/bun.js/bindings/ErrorCode.h +++ b/src/bun.js/bindings/ErrorCode.h @@ -131,6 +131,7 @@ JSC::EncodedJSValue OSSL_EVP_INVALID_DIGEST(JSC::ThrowScope&, JSC::JSGlobalObjec JSC::EncodedJSValue KEY_GENERATION_JOB_FAILED(JSC::ThrowScope&, JSC::JSGlobalObject*); JSC::EncodedJSValue INCOMPATIBLE_OPTION_PAIR(JSC::ThrowScope&, JSC::JSGlobalObject*, ASCIILiteral opt1, ASCIILiteral opt2); JSC::EncodedJSValue MISSING_OPTION(JSC::ThrowScope&, JSC::JSGlobalObject*, ASCIILiteral message); +JSC::EncodedJSValue INVALID_MIME_SYNTAX(JSC::ThrowScope&, JSC::JSGlobalObject*, const String& part, const String& input, int position); JSC::EncodedJSValue CLOSED_MESSAGE_PORT(JSC::ThrowScope&, JSC::JSGlobalObject*); JSC::EncodedJSValue INVALID_THIS(JSC::ThrowScope& scope, JSC::JSGlobalObject* globalObject, ASCIILiteral expectedType); diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 19f351f505..faf00552a1 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -128,6 +128,7 @@ const errors: ErrorCodeMapping = [ ["ERR_INVALID_FILE_URL_PATH", TypeError], ["ERR_INVALID_HTTP_TOKEN", TypeError], ["ERR_INVALID_IP_ADDRESS", TypeError], + ["ERR_INVALID_MIME_SYNTAX", TypeError], ["ERR_INVALID_MODULE", Error], ["ERR_INVALID_OBJECT_DEFINE_PROPERTY", TypeError], ["ERR_INVALID_PACKAGE_CONFIG", Error], diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index bc4ce07090..88e1ca5965 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -79,6 +79,7 @@ #include "JSBroadcastChannel.h" #include "JSBuffer.h" #include "JSBufferList.h" +#include "webcore/JSMIMEBindings.h" #include "JSByteLengthQueuingStrategy.h" #include "JSCloseEvent.h" #include "JSCommonJSExtensions.h" @@ -98,6 +99,8 @@ #include "JSEventTarget.h" #include "JSFetchHeaders.h" #include "JSFFIFunction.h" +#include "webcore/JSMIMEParams.h" +#include "webcore/JSMIMEType.h" #include "JSMessageChannel.h" #include "JSMessageEvent.h" #include "JSMessagePort.h" @@ -173,6 +176,7 @@ #include "JSSecretKeyObject.h" #include "JSPublicKeyObject.h" #include "JSPrivateKeyObject.h" +#include "webcore/JSMIMEParams.h" #include "JSS3File.h" #include "S3Error.h" #include "ProcessBindingBuffer.h" @@ -2932,6 +2936,16 @@ void GlobalObject::finishCreation(VM& vm) setupPrivateKeyObjectClassStructure(init); }); + m_JSMIMEParamsClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + WebCore::setupJSMIMEParamsClassStructure(init); + }); + + m_JSMIMETypeClassStructure.initLater( + [](LazyClassStructure::Initializer& init) { + WebCore::setupJSMIMETypeClassStructure(init); + }); + m_lazyStackCustomGetterSetter.initLater( [](const Initializer& init) { init.set(CustomGetterSetter::create(init.vm, errorInstanceLazyStackCustomGetter, errorInstanceLazyStackCustomSetter)); diff --git a/src/bun.js/bindings/ZigGlobalObject.h b/src/bun.js/bindings/ZigGlobalObject.h index 11ee147f8f..1b9592cdce 100644 --- a/src/bun.js/bindings/ZigGlobalObject.h +++ b/src/bun.js/bindings/ZigGlobalObject.h @@ -527,6 +527,8 @@ public: V(public, LazyClassStructure, m_JSSecretKeyObjectClassStructure) \ V(public, LazyClassStructure, m_JSPublicKeyObjectClassStructure) \ V(public, LazyClassStructure, m_JSPrivateKeyObjectClassStructure) \ + V(public, LazyClassStructure, m_JSMIMEParamsClassStructure) \ + V(public, LazyClassStructure, m_JSMIMETypeClassStructure) \ \ V(private, LazyPropertyOfGlobalObject, m_pendingVirtualModuleResultStructure) \ V(private, LazyPropertyOfGlobalObject, m_performMicrotaskFunction) \ @@ -567,6 +569,8 @@ public: V(private, LazyPropertyOfGlobalObject, m_importMetaObjectStructure) \ V(private, LazyPropertyOfGlobalObject, m_asyncBoundFunctionStructure) \ V(public, LazyPropertyOfGlobalObject, m_JSDOMFileConstructor) \ + V(public, LazyPropertyOfGlobalObject, m_JSMIMEParamsConstructor) \ + V(public, LazyPropertyOfGlobalObject, m_JSMIMETypeConstructor) \ \ V(private, LazyPropertyOfGlobalObject, m_JSCryptoKey) \ V(private, LazyPropertyOfGlobalObject, m_NapiExternalStructure) \ diff --git a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h index e41c3cae0b..bd97f654e9 100644 --- a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h @@ -57,6 +57,8 @@ public: std::unique_ptr m_clientSubspaceForNapiTypeTag; std::unique_ptr m_clientSubspaceForObjectTemplate; std::unique_ptr m_clientSubspaceForInternalFieldObject; + std::unique_ptr m_clientSubspaceForJSMIMEType; + std::unique_ptr m_clientSubspaceForJSMIMEParams; std::unique_ptr m_clientSubspaceForV8GlobalInternals; std::unique_ptr m_clientSubspaceForHandleScopeBuffer; std::unique_ptr m_clientSubspaceForFunctionTemplate; diff --git a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h index 269fd15a21..a7b07760f9 100644 --- a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h @@ -57,6 +57,8 @@ public: std::unique_ptr m_subspaceForV8GlobalInternals; std::unique_ptr m_subspaceForHandleScopeBuffer; std::unique_ptr m_subspaceForFunctionTemplate; + std::unique_ptr m_subspaceForJSMIMEType; + std::unique_ptr m_subspaceForJSMIMEParams; std::unique_ptr m_subspaceForV8Function; std::unique_ptr m_subspaceForJSNodeHTTPServerSocket; std::unique_ptr m_subspaceForNodeVMGlobalObject; diff --git a/src/bun.js/bindings/webcore/JSMIMEBindings.cpp b/src/bun.js/bindings/webcore/JSMIMEBindings.cpp new file mode 100644 index 0000000000..e4cc9dab9d --- /dev/null +++ b/src/bun.js/bindings/webcore/JSMIMEBindings.cpp @@ -0,0 +1,24 @@ +#include "root.h" +#include "JSMIMEParams.h" +#include "JSMIMEType.h" +#include "JavaScriptCore/JSObject.h" +#include "JavaScriptCore/JSCJSValueInlines.h" +#include "ZigGlobalObject.h" + +namespace WebCore { + +using namespace JSC; + +// Create the combined MIME binding object with both MIMEParams and MIMEType +JSValue createMIMEBinding(Zig::GlobalObject* globalObject) +{ + VM& vm = globalObject->vm(); + JSObject* obj = constructEmptyObject(globalObject); + + obj->putDirect(vm, PropertyName(Identifier::fromString(vm, "MIMEParams"_s)), globalObject->m_JSMIMEParamsClassStructure.constructor(globalObject)); + obj->putDirect(vm, PropertyName(Identifier::fromString(vm, "MIMEType"_s)), globalObject->m_JSMIMETypeClassStructure.constructor(globalObject)); + + return obj; +} + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSMIMEBindings.h b/src/bun.js/bindings/webcore/JSMIMEBindings.h new file mode 100644 index 0000000000..453364e8f1 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSMIMEBindings.h @@ -0,0 +1,14 @@ +#pragma once + +#include "root.h" + +namespace Zig { +class GlobalObject; +} + +namespace WebCore { + +// Function to create a unified MIME binding object +JSC::JSValue createMIMEBinding(Zig::GlobalObject* globalObject); + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSMIMEParams.cpp b/src/bun.js/bindings/webcore/JSMIMEParams.cpp new file mode 100644 index 0000000000..3fca56a6e7 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSMIMEParams.cpp @@ -0,0 +1,721 @@ +#include "root.h" +#include "JSMIMEParams.h" + +#include "JavaScriptCore/JSObject.h" +#include "JavaScriptCore/JSMap.h" +#include "JavaScriptCore/JSMapIterator.h" +#include "JavaScriptCore/InternalFunction.h" +#include "JavaScriptCore/JSCJSValueInlines.h" +#include "JavaScriptCore/JSGlobalObject.h" +#include "JavaScriptCore/FunctionPrototype.h" +#include "JavaScriptCore/ObjectConstructor.h" +#include "JavaScriptCore/StructureInlines.h" +#include "JavaScriptCore/IteratorOperations.h" +#include "JavaScriptCore/PropertySlot.h" +#include "JavaScriptCore/SlotVisitorMacros.h" +#include "JavaScriptCore/SubspaceInlines.h" +#include "wtf/text/StringBuilder.h" +#include "wtf/text/WTFString.h" +#include "wtf/ASCIICType.h" +#include "ZigGlobalObject.h" +#include "NodeValidator.h" // For Bun::V:: +#include "ErrorCode.h" // For Bun::ERR:: +#include "JavaScriptCore/JSMapInlines.h" + +namespace WebCore { + +using namespace JSC; +using namespace WTF; + +//-- Helper Functions (Adapted from mime.ts & HTTPParsers.h) -- + +// Checks if a character is an HTTP token code point. +// Equivalent to /[^!#$%&'*+\-.^_`|~A-Za-z0-9]/ +static inline bool isHTTPTokenChar(char c) +{ + return WTF::isASCIIAlphanumeric(c) || c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; +} + +// Finds the first character that is NOT an HTTP token code point. Returns -1 if all are valid. +static int findFirstInvalidHTTPTokenChar(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + if (!isHTTPTokenChar(span[i])) { + return i; + } + } + } else { + const auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + // Assume non-ASCII is invalid for tokens + if (span[i] > 0x7F || !isHTTPTokenChar(static_cast(span[i]))) { + return i; + } + } + } + return -1; +} + +// Checks if a character is valid within an HTTP quoted string value (excluding DQUOTE and backslash). +// Equivalent to /[^\t\u0020-\u007E\u0080-\u00FF]/, but we handle quotes/backslash separately. +static inline bool isHTTPQuotedStringChar(UChar c) +{ + return c == 0x09 || (c >= 0x20 && c <= 0x7E) || (c >= 0x80 && c <= 0xFF); +} + +// Finds the first invalid character in a potential parameter value. Returns -1 if all are valid. +static int findFirstInvalidHTTPQuotedStringChar(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + if (!isHTTPQuotedStringChar(span[i])) { + return i; + } + } + } else { + const auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + if (!isHTTPQuotedStringChar(span[i])) { + return i; + } + } + } + return -1; +} + +// Equivalent to /[^\r\n\t ]|$/ +static size_t findEndBeginningWhitespace(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + char c = span[i]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return span.size(); + } else { + const auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + UChar c = span[i]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return span.size(); + } +} + +// Equivalent to /[\r\n\t ]*$/ +static size_t findStartEndingWhitespace(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = span.size(); i > 0; --i) { + char c = span[i - 1]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return 0; + } else { + const auto span = view.span16(); + for (size_t i = span.size(); i > 0; --i) { + UChar c = span[i - 1]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return 0; + } +} + +static String removeBackslashes(const StringView& view) +{ + if (view.find('\\') == notFound) { + return view.toString(); + } + + StringBuilder builder; + if (view.is8Bit()) { + auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + LChar c = span[i]; + if (c == '\\' && i + 1 < span.size()) { + builder.append(span[++i]); + } else { + builder.append(c); + } + } + } else { + auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + UChar c = span[i]; + if (c == '\\' && i + 1 < span.size()) { + builder.append(span[++i]); + } else { + builder.append(c); + } + } + } + return builder.toString(); +} + +static void escapeQuoteOrBackslash(const StringView& view, StringBuilder& builder) +{ + if (view.find([](UChar c) { return c == '"' || c == '\\'; }) == notFound) { + builder.append(view); + return; + } + + if (view.is8Bit()) { + auto span = view.span8(); + for (LChar c : span) { + if (c == '"' || c == '\\') { + builder.append('\\'); + } + builder.append(c); + } + } else { + auto span = view.span16(); + for (UChar c : span) { + if (c == '"' || c == '\\') { + builder.append('\\'); + } + builder.append(c); + } + } +} + +// Encodes a parameter value for serialization. +static void encodeParamValue(const StringView& value, StringBuilder& builder) +{ + if (value.isEmpty()) { + builder.append("\"\""_s); + return; + } + if (findFirstInvalidHTTPTokenChar(value) == -1) { + // It's a simple token, no quoting needed. + builder.append(value); + return; + } + // Needs quoting and escaping. + builder.append('"'); + escapeQuoteOrBackslash(value, builder); + builder.append('"'); +} + +// Parses the parameter string and populates the map. +// Returns true on success, false on failure (exception should be set). +bool parseMIMEParamsString(JSGlobalObject* globalObject, JSMap* map, StringView input) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + size_t position = 0; + size_t length = input.length(); + + while (position < length) { + // Skip whitespace and the semicolon separator + position += findEndBeginningWhitespace(input.substring(position)); + if (position >= length) break; // End of string after whitespace + + // Find the end of the parameter name (next ';' or '=') + size_t nameEnd = position; + while (nameEnd < length) { + UChar c = input[nameEnd]; + if (c == ';' || c == '=') break; + nameEnd++; + } + + StringView nameView = input.substring(position, nameEnd - position); + String name = nameView.convertToASCIILowercase(); + position = nameEnd; + + StringView valueView; + String valueStr; + + // Check if there's a value part (an '=' sign) + if (position < length && input[position] == '=') { + position++; // Skip '=' + + if (position < length && input[position] == '"') { + // Quoted string value + position++; // Skip opening quote + size_t valueStart = position; + bool escaped = false; + while (position < length) { + UChar c = input[position]; + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + break; // Found closing quote + } + position++; + } + valueView = input.substring(valueStart, position - valueStart); + valueStr = removeBackslashes(valueView); + + if (position < length && input[position] == '"') { + position++; // Skip closing quote + } else { + // Node.js behavior seems to allow this, just consuming until the end or next semicolon + valueStr = removeBackslashes(valueView); + size_t semicolonPos = input.find(';', position); + position = (semicolonPos == notFound) ? length : semicolonPos; + } + } else { + // Token value (potentially empty) + size_t valueEnd = position; + while (valueEnd < length && input[valueEnd] != ';') { + valueEnd++; + } + valueView = input.substring(position, valueEnd - position); + // Trim trailing whitespace + size_t trimmedEnd = findStartEndingWhitespace(valueView); + valueStr = valueView.substring(0, trimmedEnd).toString(); + position = valueEnd; + if (valueStr.isEmpty()) { + continue; // skip adding this parameter + } + } + } else { + // Parameter name without a value (e.g., ";foo;") - Node ignores these. + // Skip until the next semicolon or end of string. + size_t semicolonPos = input.find(';', position); + position = (semicolonPos == notFound) ? length : semicolonPos; + // Skip the potential ';' + if (position < length && input[position] == ';') position++; + continue; // Skip adding this parameter + } + + // Validate name and value according to HTTP token/quoted-string rules + int invalidNameIndex = findFirstInvalidHTTPTokenChar(name); + if (name.isEmpty() || invalidNameIndex != -1) { + // invalid name + continue; // skip adding this parameter + } + + int invalidValueIndex = findFirstInvalidHTTPQuotedStringChar(valueStr); + if (invalidValueIndex != -1) { + // invalid value + continue; // skip adding this parameter + } + + // Add to map only if the name doesn't exist yet (first one wins) + JSValue nameJS = jsString(vm, name); + if (!map->has(globalObject, nameJS)) { + map->set(globalObject, nameJS, jsString(vm, valueStr)); + RETURN_IF_EXCEPTION(scope, false); + } + + // Skip the potential trailing semicolon + if (position < length && input[position] == ';') { + position++; + } + } + + return true; +} + +//-- JSMIMEParams (Instance) Implementation -- + +const ClassInfo JSMIMEParams::s_info = { "MIMEParams"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMIMEParams) }; + +JSMIMEParams* JSMIMEParams::create(VM& vm, Structure* structure, JSMap* map) +{ + JSMIMEParams* instance = new (NotNull, allocateCell(vm)) JSMIMEParams(vm, structure); + instance->finishCreation(vm, map); + return instance; +} + +Structure* JSMIMEParams::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); +} + +JSMIMEParams::JSMIMEParams(VM& vm, Structure* structure) + : Base(vm, structure) +{ +} + +void JSMIMEParams::finishCreation(VM& vm, JSMap* map) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + m_map.set(vm, this, map); +} + +template +void JSMIMEParams::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSMIMEParams* thisObject = jsCast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_map); +} + +DEFINE_VISIT_CHILDREN(JSMIMEParams); + +//-- JSMIMEParamsPrototype Implementation -- + +const ClassInfo JSMIMEParamsPrototype::s_info = { "MIMEParams"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMIMEParamsPrototype) }; + +JSMIMEParamsPrototype* JSMIMEParamsPrototype::create(VM& vm, JSGlobalObject* globalObject, Structure* structure) +{ + JSMIMEParamsPrototype* prototype = new (NotNull, allocateCell(vm)) JSMIMEParamsPrototype(vm, structure); + prototype->finishCreation(vm); + return prototype; +} + +Structure* JSMIMEParamsPrototype::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); +} + +JSMIMEParamsPrototype::JSMIMEParamsPrototype(VM& vm, Structure* structure) + : Base(vm, structure) +{ +} + +// Host function implementations +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncGet, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + // 1. Get this value + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + + // 2. Get argument + JSValue nameValue = callFrame->argument(0); + String name = nameValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + // 3. Perform operation on the map + JSMap* map = thisObject->jsMap(); + if (!map->has(globalObject, jsString(vm, name))) { + return JSValue::encode(jsNull()); + } + JSValue result = map->get(globalObject, jsString(vm, name)); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + // 4. Return result (null if not found) + return JSValue::encode(result); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncHas, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + + JSValue nameValue = callFrame->argument(0); + String name = nameValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + JSMap* map = thisObject->jsMap(); + bool result = map->has(globalObject, jsString(vm, name)); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + return JSValue::encode(jsBoolean(result)); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncSet, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + + // 1. Validate Arguments + JSValue nameValue = callFrame->argument(0); + JSValue valueValue = callFrame->argument(1); + + String nameStr = nameValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + String valueStr = valueValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + // Validate name (must be a valid HTTP token) + int invalidNameIndex = findFirstInvalidHTTPTokenChar(nameStr); + if (nameStr.isEmpty() || invalidNameIndex != -1) { + scope.release(); + return Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "parameter name"_s, nameStr, invalidNameIndex); + } + + // Validate value (must contain only valid quoted-string characters) + int invalidValueIndex = findFirstInvalidHTTPQuotedStringChar(valueStr); + if (invalidValueIndex != -1) { + scope.release(); + return Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "parameter value"_s, valueStr, invalidValueIndex); + } + + // 2. Perform Set Operation + JSMap* map = thisObject->jsMap(); + map->set(globalObject, jsString(vm, nameStr), jsString(vm, valueStr)); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncDelete, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + + JSValue nameValue = callFrame->argument(0); + String name = nameValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + JSMap* map = thisObject->jsMap(); + map->remove(globalObject, jsString(vm, name)); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncToString, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + + JSMap* map = thisObject->jsMap(); + StringBuilder builder; + bool first = true; + + JSValue iteratorValue = JSMapIterator::create(globalObject, globalObject->mapIteratorStructure(), map, IterationKind::Entries); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + JSMapIterator* iterator = jsDynamicCast(iteratorValue); + if (!iterator) { // Should not happen for JSMap.entries() + scope.release(); + return Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "Internal error: Expected MapIterator"_s, "toString"_s, -1); + } + + while (true) { + JSValue nextValue; + if (!iterator->next(globalObject, nextValue)) break; + + JSArray* entry = jsDynamicCast(nextValue); + if (!entry || entry->length() < 2) // Should not happen + continue; + + JSValue keyJS = entry->getIndex(globalObject, 0); + JSValue valueJS = entry->getIndex(globalObject, 1); + + // Key should already be lowercase string from set/constructor + String key = keyJS.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + String value = valueJS.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, encodedJSValue()); + + if (!first) { + builder.append(';'); + } + first = false; + + builder.append(key); + builder.append('='); + encodeParamValue(value, builder); + } + + return JSValue::encode(jsString(vm, builder.toString())); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncEntries, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(JSMapIterator::create(globalObject, globalObject->mapIteratorStructure(), thisObject->jsMap(), IterationKind::Entries)); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncKeys, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(JSMapIterator::create(globalObject, globalObject->mapIteratorStructure(), thisObject->jsMap(), IterationKind::Keys)); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncValues, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEParams")); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(JSMapIterator::create(globalObject, globalObject->mapIteratorStructure(), thisObject->jsMap(), IterationKind::Values)); +} + +// Forward declare constructor functions +JSC_DECLARE_HOST_FUNCTION(callMIMEParams); +JSC_DECLARE_HOST_FUNCTION(constructMIMEParams); + +// Define the properties and functions on the prototype +static const HashTableValue JSMIMEParamsPrototypeTableValues[] = { + { "get"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncGet, 1 } }, + { "has"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncHas, 1 } }, + { "set"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncSet, 2 } }, + { "delete"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncDelete, 1 } }, + { "toString"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncToString, 0 } }, + { "entries"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncEntries, 0 } }, + { "keys"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncKeys, 0 } }, + { "values"_s, static_cast(PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMEParamsProtoFuncValues, 0 } }, +}; + +void JSMIMEParamsPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSMIMEParams::info(), JSMIMEParamsPrototypeTableValues, *this); + + // Set [Symbol.iterator] to entries + putDirectWithoutTransition(vm, vm.propertyNames->iteratorSymbol, getDirect(vm, Identifier::fromString(vm, "entries"_s)), PropertyAttribute::DontEnum | 0); + + // Set toJSON to toString + putDirectWithoutTransition(vm, vm.propertyNames->toJSON, getDirect(vm, vm.propertyNames->toString), PropertyAttribute::Function | 0); + + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +//-- JSMIMEParamsConstructor Implementation -- + +const ClassInfo JSMIMEParamsConstructor::s_info = { "MIMEParams"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMIMEParamsConstructor) }; + +JSMIMEParamsConstructor* JSMIMEParamsConstructor::create(VM& vm, Structure* structure, JSObject* prototype) +{ + JSMIMEParamsConstructor* constructor = new (NotNull, JSC::allocateCell(vm)) JSMIMEParamsConstructor(vm, structure); + constructor->finishCreation(vm, prototype); + return constructor; +} + +Structure* JSMIMEParamsConstructor::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(InternalFunctionType, StructureFlags), info()); +} + +JSMIMEParamsConstructor::JSMIMEParamsConstructor(VM& vm, Structure* structure) + : Base(vm, structure, callMIMEParams, constructMIMEParams) +{ +} + +JSC_DEFINE_HOST_FUNCTION(callMIMEParams, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + return throwVMError(globalObject, scope, createNotAConstructorError(globalObject, callFrame->jsCallee())); +} + +JSC_DEFINE_HOST_FUNCTION(constructMIMEParams, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + + JSC::VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* zigGlobalObject = defaultGlobalObject(globalObject); + JSC::Structure* structure = zigGlobalObject->m_JSMIMEParamsClassStructure.get(zigGlobalObject); + + JSC::JSValue newTarget = callFrame->newTarget(); + if (UNLIKELY(zigGlobalObject->m_JSMIMEParamsClassStructure.constructor(zigGlobalObject) != newTarget)) { + if (!newTarget) { + throwTypeError(globalObject, scope, "Class constructor MIMEParams cannot be invoked without 'new'"_s); + return {}; + } + + auto* functionGlobalObject = defaultGlobalObject(getFunctionRealm(globalObject, newTarget.getObject())); + RETURN_IF_EXCEPTION(scope, {}); + structure = JSC::InternalFunction::createSubclassStructure(globalObject, newTarget.getObject(), functionGlobalObject->m_JSMIMEParamsClassStructure.get(functionGlobalObject)); + RETURN_IF_EXCEPTION(scope, {}); + } + + // Create the internal JSMap + JSMap* map = JSMap::create(vm, globalObject->mapStructure()); + RETURN_IF_EXCEPTION(scope, {}); // OOM check + + // Create the JSMIMEParams instance + JSMIMEParams* instance = JSMIMEParams::create(vm, structure, map); + + return JSC::JSValue::encode(instance); +} + +void JSMIMEParamsConstructor::finishCreation(VM& vm, JSObject* prototype) +{ + Base::finishCreation(vm, 0, "MIMEParams"_s); + putDirectWithoutTransition(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); +} + +//-- Structure Setup -- + +void setupJSMIMEParamsClassStructure(LazyClassStructure::Initializer& init) +{ + VM& vm = init.vm; + JSGlobalObject* globalObject = init.global; + + // Create Prototype + auto* prototypeStructure = JSMIMEParamsPrototype::createStructure(vm, globalObject, globalObject->objectPrototype()); + auto* prototype = JSMIMEParamsPrototype::create(vm, globalObject, prototypeStructure); + + // Create Constructor + auto* constructorStructure = JSMIMEParamsConstructor::createStructure(vm, globalObject, globalObject->functionPrototype()); + auto* constructor = JSMIMEParamsConstructor::create(vm, constructorStructure, prototype); + + // Create Instance Structure + auto* instanceStructure = JSMIMEParams::createStructure(vm, globalObject, prototype); + + init.setPrototype(prototype); + init.setStructure(instanceStructure); + init.setConstructor(constructor); +} + +JSValue createJSMIMEBinding(Zig::GlobalObject* globalObject) +{ + VM& vm = globalObject->vm(); + JSObject* obj = constructEmptyObject(globalObject); + + obj->putDirect(vm, PropertyName(Identifier::fromString(vm, "MIMEParams"_s)), + globalObject->m_JSMIMEParamsClassStructure.constructor(globalObject)); + obj->putDirect(vm, PropertyName(Identifier::fromString(vm, "MIMEType"_s)), + globalObject->m_JSMIMETypeClassStructure.constructor(globalObject)); + + return obj; +} + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSMIMEParams.h b/src/bun.js/bindings/webcore/JSMIMEParams.h new file mode 100644 index 0000000000..a40bbe8fa8 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSMIMEParams.h @@ -0,0 +1,94 @@ +#pragma once + +#include "root.h" +#include "JSDOMWrapper.h" // For JSDOMObject +#include +#include +#include +#include +#include + +namespace WebCore { + +class JSMIMEParams final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForJSMIMEParams.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForJSMIMEParams = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForJSMIMEParams.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForJSMIMEParams = std::forward(space); }); + } + + static JSMIMEParams* create(JSC::VM& vm, JSC::Structure* structure, JSC::JSMap* map); + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + JSC::JSMap* jsMap() const { return m_map.get(); } + +private: + JSMIMEParams(JSC::VM& vm, JSC::Structure* structure); + void finishCreation(JSC::VM& vm, JSC::JSMap* map); + + JSC::WriteBarrier m_map; +}; + +class JSMIMEParamsPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::ImplementsDefaultHasInstance; + + static JSMIMEParamsPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure); + + DECLARE_INFO; + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); + +private: + JSMIMEParamsPrototype(JSC::VM& vm, JSC::Structure* structure); + void finishCreation(JSC::VM& vm); +}; + +class JSMIMEParamsConstructor final : public JSC::InternalFunction { +public: + using Base = JSC::InternalFunction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSMIMEParamsConstructor* create(JSC::VM& vm, JSC::Structure* structure, JSC::JSObject* prototype); + + DECLARE_INFO; + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + return &vm.internalFunctionSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); + +private: + JSMIMEParamsConstructor(JSC::VM& vm, JSC::Structure* structure); + void finishCreation(JSC::VM& vm, JSC::JSObject* prototype); +}; + +// Function to setup the structures lazily +void setupJSMIMEParamsClassStructure(JSC::LazyClassStructure::Initializer&); + +JSC::JSValue createJSMIMEBinding(Zig::GlobalObject* globalObject); +bool parseMIMEParamsString(JSGlobalObject* globalObject, JSMap* map, StringView input); + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSMIMEType.cpp b/src/bun.js/bindings/webcore/JSMIMEType.cpp new file mode 100644 index 0000000000..7fec9c5ac0 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSMIMEType.cpp @@ -0,0 +1,617 @@ +#include "root.h" +#include "JSMIMEType.h" + +#include "JSMIMEParams.h" // For JSMIMEParams and helper functions +#include "JavaScriptCore/JSObject.h" +#include "JavaScriptCore/JSCJSValueInlines.h" +#include "JavaScriptCore/JSGlobalObject.h" +#include "JavaScriptCore/FunctionPrototype.h" +#include "JavaScriptCore/ObjectConstructor.h" +#include "JavaScriptCore/StructureInlines.h" +#include "JavaScriptCore/JSMap.h" // For map creation in constructor +#include "JavaScriptCore/PropertySlot.h" +#include "JavaScriptCore/SlotVisitorMacros.h" +#include "JavaScriptCore/SubspaceInlines.h" +#include "wtf/text/StringBuilder.h" +#include "wtf/text/WTFString.h" +#include "wtf/ASCIICType.h" +#include "ZigGlobalObject.h" +#include "NodeValidator.h" // For Bun::V:: +#include "ErrorCode.h" // For Bun::ERR:: +#include "JavaScriptCore/JSMapInlines.h" + +namespace WebCore { + +using namespace JSC; +using namespace WTF; + +// Helper functions - redeclared as static to avoid linker errors +// Checks if a character is an HTTP token code point. +// Equivalent to /[^!#$%&'*+\-.^_`|~A-Za-z0-9]/ +static inline bool isHTTPTokenChar(char c) +{ + return WTF::isASCIIAlphanumeric(c) || c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; +} + +// Checks if a character is NOT an HTTP token code point. +static inline bool isNotHTTPTokenChar(char c) +{ + return !isHTTPTokenChar(c); +} + +// Finds the first character that is NOT an HTTP token code point. Returns -1 if all are valid. +static int findFirstInvalidHTTPTokenChar(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + if (isNotHTTPTokenChar(span[i])) { + return i; + } + } + } else { + const auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + // Assume non-ASCII is invalid for tokens + if (span[i] > 0x7F || isNotHTTPTokenChar(static_cast(span[i]))) { + return i; + } + } + } + return -1; +} + +// Checks if a character is valid within an HTTP quoted string value (excluding DQUOTE and backslash). +// Equivalent to /[^\t\u0020-\u007E\u0080-\u00FF]/, but we handle quotes/backslash separately. +static inline bool isHTTPQuotedStringChar(UChar c) +{ + return c == 0x09 || (c >= 0x20 && c <= 0x7E) || (c >= 0x80 && c <= 0xFF); +} + +// Checks if a character is NOT a valid HTTP quoted string code point. +static inline bool isNotHTTPQuotedStringChar(UChar c) +{ + return !isHTTPQuotedStringChar(c); +} + +// Finds the first invalid character in a potential parameter value. Returns -1 if all are valid. +static int findFirstInvalidHTTPQuotedStringChar(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + if (isNotHTTPQuotedStringChar(span[i])) { + return i; + } + } + } else { + const auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + if (isNotHTTPQuotedStringChar(span[i])) { + return i; + } + } + } + return -1; +} + +// Equivalent to /[^\r\n\t ]|$/ +static size_t findEndBeginningWhitespace(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + char c = span[i]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return span.size(); + } else { + const auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + UChar c = span[i]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return span.size(); + } +} + +// Equivalent to /[\r\n\t ]*$/ +static size_t findStartEndingWhitespace(const StringView& view) +{ + if (view.is8Bit()) { + const auto span = view.span8(); + for (size_t i = span.size(); i > 0; --i) { + char c = span[i - 1]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return 0; + } else { + const auto span = view.span16(); + for (size_t i = span.size(); i > 0; --i) { + UChar c = span[i - 1]; + if (c != '\t' && c != ' ' && c != '\r' && c != '\n') { + return i; + } + } + return 0; + } +} + +static String removeBackslashes(const StringView& view) +{ + if (view.find('\\') == notFound) { + return view.toString(); + } + + StringBuilder builder; + if (view.is8Bit()) { + auto span = view.span8(); + for (size_t i = 0; i < span.size(); ++i) { + LChar c = span[i]; + if (c == '\\' && i + 1 < span.size()) { + builder.append(span[++i]); + } else { + builder.append(c); + } + } + } else { + auto span = view.span16(); + for (size_t i = 0; i < span.size(); ++i) { + UChar c = span[i]; + if (c == '\\' && i + 1 < span.size()) { + builder.append(span[++i]); + } else { + builder.append(c); + } + } + } + return builder.toString(); +} + +static String escapeQuoteOrBackslash(const StringView& view) +{ + if (view.find([](UChar c) { return c == '"' || c == '\\'; }) == notFound) { + return view.toString(); + } + + StringBuilder builder; + if (view.is8Bit()) { + auto span = view.span8(); + for (LChar c : span) { + if (c == '"' || c == '\\') { + builder.append('\\'); + } + builder.append(c); + } + } else { + auto span = view.span16(); + for (UChar c : span) { + if (c == '"' || c == '\\') { + builder.append('\\'); + } + builder.append(c); + } + } + return builder.toString(); +} + +// Encodes a parameter value for serialization. +static String encodeParamValue(const StringView& value) +{ + if (value.isEmpty()) { + return "\"\""_s; + } + if (findFirstInvalidHTTPTokenChar(value) == -1) { + // It's a simple token, no quoting needed. + return value.toString(); + } + // Needs quoting and escaping. + return makeString('"', escapeQuoteOrBackslash(value), '"'); +} + +// Helper to parse type/subtype +// Returns {type, subtype, parameters_start_index} or throws on error +static std::tuple parseTypeAndSubtype(JSGlobalObject* globalObject, GCOwnedDataScope& input) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + size_t position = findEndBeginningWhitespace(input); + size_t length = input->length(); + + // Find end of type + size_t typeEnd = input->find('/', position); + if (typeEnd == notFound) { + StringView remaining = input->substring(position); + size_t invalidIndex = findFirstInvalidHTTPTokenChar(remaining); + // Adjust index relative to original string + size_t originalIndex = (invalidIndex == -1) ? notFound : position + invalidIndex; + Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "type"_s, input->toString(), originalIndex); + return {}; + } + + StringView typeView = input->substring(position, typeEnd - position); + int invalidTypeIndex = findFirstInvalidHTTPTokenChar(typeView); + if (typeView.isEmpty() || invalidTypeIndex != -1) { + size_t originalIndex = (invalidTypeIndex == -1) ? position : position + invalidTypeIndex; + Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "type"_s, input->toString(), originalIndex); + return {}; + } + String type = typeView.convertToASCIILowercase(); + position = typeEnd + 1; // Skip '/' + + // Find end of subtype + size_t subtypeEnd = input->find(';', position); + size_t paramsStartIndex; + StringView rawSubtypeView; + + if (subtypeEnd == notFound) { + rawSubtypeView = input->substring(position); + paramsStartIndex = length; // Parameters start at the end if no ';' + } else { + rawSubtypeView = input->substring(position, subtypeEnd - position); + paramsStartIndex = subtypeEnd + 1; // Parameters start after ';' + } + + // Trim trailing whitespace from subtype + size_t trimmedSubtypeEnd = findStartEndingWhitespace(rawSubtypeView); + StringView subtypeView = rawSubtypeView.left(trimmedSubtypeEnd); + + int invalidSubtypeIndex = findFirstInvalidHTTPTokenChar(subtypeView); + if (subtypeView.isEmpty() || invalidSubtypeIndex != -1) { + size_t originalIndex = (invalidSubtypeIndex == -1) ? position : position + invalidSubtypeIndex; + Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "subtype"_s, input->toString(), originalIndex); + return {}; + } + String subtype = subtypeView.convertToASCIILowercase(); + + // Return type, subtype, and the index where parameters start + return std::make_tuple(WTFMove(type), WTFMove(subtype), paramsStartIndex); +} + +//-- JSMIMEType (Instance) Implementation -- + +const ClassInfo JSMIMEType::s_info = { "MIMEType"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMIMEType) }; + +JSMIMEType* JSMIMEType::create(VM& vm, Structure* structure, String type, String subtype, JSMIMEParams* params) +{ + JSMIMEType* instance = new (NotNull, allocateCell(vm)) JSMIMEType(vm, structure); + instance->finishCreation(vm, WTFMove(type), WTFMove(subtype), params); + return instance; +} + +Structure* JSMIMEType::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); +} + +JSMIMEType::JSMIMEType(VM& vm, Structure* structure) + : Base(vm, structure) +{ +} + +void JSMIMEType::finishCreation(VM& vm, String type, String subtype, JSMIMEParams* params) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + m_type = WTFMove(type); + m_subtype = WTFMove(subtype); + m_parameters.set(vm, this, params); +} + +template +void JSMIMEType::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSMIMEType* thisObject = jsCast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_parameters); + // m_type and m_subtype are WTF::String, not GC managed cells +} + +DEFINE_VISIT_CHILDREN(JSMIMEType); + +//-- JSMIMETypePrototype Implementation -- + +const ClassInfo JSMIMETypePrototype::s_info = { "MIMEType"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMIMETypePrototype) }; + +JSMIMETypePrototype* JSMIMETypePrototype::create(VM& vm, JSGlobalObject* globalObject, Structure* structure) +{ + JSMIMETypePrototype* prototype = new (NotNull, allocateCell(vm)) JSMIMETypePrototype(vm, structure); + prototype->finishCreation(vm); + return prototype; +} + +Structure* JSMIMETypePrototype::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), info()); +} + +JSMIMETypePrototype::JSMIMETypePrototype(VM& vm, Structure* structure) + : Base(vm, structure) +{ +} + +// Host function implementations +JSC_DEFINE_CUSTOM_GETTER(jsMIMETypeProtoGetterType, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEType")); + return {}; + } + + return JSValue::encode(jsString(vm, thisObject->type())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsMIMETypeProtoSetterType, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEType")); + return {}; + } + + JSValue value = JSValue::decode(encodedValue); + String typeStr = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + // Validate type + int invalidIndex = findFirstInvalidHTTPTokenChar(typeStr); + if (typeStr.isEmpty() || invalidIndex != -1) { + Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "type"_s, typeStr, invalidIndex); + return {}; + } + + thisObject->setType(typeStr.convertToASCIILowercase()); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_CUSTOM_GETTER(jsMIMETypeProtoGetterSubtype, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEType")); + return {}; + } + + return JSValue::encode(jsString(vm, thisObject->subtype())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsMIMETypeProtoSetterSubtype, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEType")); + return {}; + } + + JSValue value = JSValue::decode(encodedValue); + String subtypeStr = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + // Validate subtype + int invalidIndex = findFirstInvalidHTTPTokenChar(subtypeStr); + if (subtypeStr.isEmpty() || invalidIndex != -1) { + Bun::ERR::INVALID_MIME_SYNTAX(scope, globalObject, "subtype"_s, subtypeStr, invalidIndex); + return {}; + } + + thisObject->setSubtype(subtypeStr.convertToASCIILowercase()); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_CUSTOM_GETTER(jsMIMETypeProtoGetterEssence, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEType")); + return {}; + } + + String essence = makeString(thisObject->type(), '/', thisObject->subtype()); + return JSValue::encode(jsString(vm, essence)); +} + +JSC_DEFINE_CUSTOM_GETTER(jsMIMETypeProtoGetterParams, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(JSC::JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEType")); + return {}; + } + + return JSValue::encode(thisObject->parameters()); +} + +JSC_DEFINE_HOST_FUNCTION(jsMIMETypeProtoFuncToString, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* thisObject = jsDynamicCast(callFrame->thisValue()); + if (UNLIKELY(!thisObject)) { + scope.throwException(globalObject, Bun::createInvalidThisError(globalObject, thisObject, "MIMEType")); + return {}; + } + + // Call the JSMIMEParams toString method + JSValue paramsObject = thisObject->parameters(); + RETURN_IF_EXCEPTION(scope, {}); + + MarkedArgumentBuffer args; + JSValue paramsStrValue = paramsObject.toString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + String paramsStr = paramsStrValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + StringBuilder builder; + builder.append(thisObject->type()); + builder.append('/'); + builder.append(thisObject->subtype()); + if (!paramsStr.isEmpty()) { + builder.append(';'); + builder.append(paramsStr); + } + + return JSValue::encode(jsString(vm, builder.toString())); +} + +// Define the properties and functions on the prototype +static const HashTableValue JSMIMETypePrototypeValues[] = { + { "type"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsMIMETypeProtoGetterType, jsMIMETypeProtoSetterType } }, + { "subtype"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsMIMETypeProtoGetterSubtype, jsMIMETypeProtoSetterSubtype } }, + { "essence"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsMIMETypeProtoGetterEssence, 0 } }, + { "params"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsMIMETypeProtoGetterParams, 0 } }, + { "toString"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMIMETypeProtoFuncToString, 0 } }, +}; + +void JSMIMETypePrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + + // Add regular methods + reifyStaticProperties(vm, JSMIMEType::info(), JSMIMETypePrototypeValues, *this); + + // Set toJSON to toString + putDirectWithoutTransition(vm, vm.propertyNames->toJSON, getDirect(vm, vm.propertyNames->toString), PropertyAttribute::Function | 0); + + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +//-- JSMIMETypeConstructor Implementation -- + +const ClassInfo JSMIMETypeConstructor::s_info = { "MIMEType"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMIMETypeConstructor) }; + +JSMIMETypeConstructor* JSMIMETypeConstructor::create(VM& vm, Structure* structure, JSObject* prototype) +{ + JSMIMETypeConstructor* constructor = new (NotNull, JSC::allocateCell(vm)) JSMIMETypeConstructor(vm, structure); + constructor->finishCreation(vm, prototype); + return constructor; +} + +Structure* JSMIMETypeConstructor::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) +{ + return Structure::create(vm, globalObject, prototype, TypeInfo(InternalFunctionType, StructureFlags), info()); +} + +JSC_DECLARE_HOST_FUNCTION(callMIMEType); +JSC_DECLARE_HOST_FUNCTION(constructMIMEType); + +JSMIMETypeConstructor::JSMIMETypeConstructor(VM& vm, Structure* structure) + : Base(vm, structure, callMIMEType, constructMIMEType) +{ +} + +JSC_DEFINE_HOST_FUNCTION(callMIMEType, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + return throwVMError(globalObject, scope, createNotAConstructorError(globalObject, callFrame->jsCallee())); +} + +JSC_DEFINE_HOST_FUNCTION(constructMIMEType, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + JSC::VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* zigGlobalObject = defaultGlobalObject(globalObject); + JSC::Structure* structure = zigGlobalObject->m_JSMIMETypeClassStructure.get(zigGlobalObject); + + JSC::JSValue newTarget = callFrame->newTarget(); + if (UNLIKELY(zigGlobalObject->m_JSMIMETypeClassStructure.constructor(zigGlobalObject) != newTarget)) { + if (!newTarget) { + throwTypeError(globalObject, scope, "Class constructor MIMEType cannot be invoked without 'new'"_s); + return {}; + } + + auto* functionGlobalObject = defaultGlobalObject(getFunctionRealm(globalObject, newTarget.getObject())); + RETURN_IF_EXCEPTION(scope, {}); + structure = JSC::InternalFunction::createSubclassStructure(globalObject, newTarget.getObject(), functionGlobalObject->m_JSMIMETypeClassStructure.get(functionGlobalObject)); + RETURN_IF_EXCEPTION(scope, {}); + } + + // 1. Get input string + JSValue inputArg = callFrame->argument(0); + auto* jsInputString = inputArg.toString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto inputString = jsInputString->view(globalObject); + + // 2. Parse type and subtype + String type, subtype; + size_t paramsStartIndex; + std::tie(type, subtype, paramsStartIndex) = parseTypeAndSubtype(globalObject, inputString); + RETURN_IF_EXCEPTION(scope, {}); // Check if parseTypeAndSubtype threw + + // 3. Create and parse parameters + // We need the structure for JSMIMEParams to create the map and the instance + JSC::Structure* paramsStructure = zigGlobalObject->m_JSMIMEParamsClassStructure.get(zigGlobalObject); + JSMap* paramsMap = JSMap::create(vm, globalObject->mapStructure()); + RETURN_IF_EXCEPTION(scope, {}); // OOM check for map + + auto paramsStringView = inputString->substring(paramsStartIndex); + parseMIMEParamsString(globalObject, paramsMap, paramsStringView); + RETURN_IF_EXCEPTION(scope, {}); + + JSMIMEParams* paramsInstance = JSMIMEParams::create(vm, paramsStructure, paramsMap); + + // 4. Create the JSMIMEType instance + JSMIMEType* instance = JSMIMEType::create(vm, structure, WTFMove(type), WTFMove(subtype), paramsInstance); + + return JSC::JSValue::encode(instance); +} + +void JSMIMETypeConstructor::finishCreation(VM& vm, JSObject* prototype) +{ + Base::finishCreation(vm, 1, "MIMEType"_s); // Constructor length is 1 + putDirectWithoutTransition(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); +} + +//-- Structure Setup -- + +void setupJSMIMETypeClassStructure(LazyClassStructure::Initializer& init) +{ + VM& vm = init.vm; + JSGlobalObject* globalObject = init.global; + + // Create Prototype + auto* prototypeStructure = JSMIMETypePrototype::createStructure(vm, globalObject, globalObject->objectPrototype()); + auto* prototype = JSMIMETypePrototype::create(vm, globalObject, prototypeStructure); + + // Create Constructor + auto* constructorStructure = JSMIMETypeConstructor::createStructure(vm, globalObject, globalObject->functionPrototype()); + auto* constructor = JSMIMETypeConstructor::create(vm, constructorStructure, prototype); + + // Create Instance Structure + auto* instanceStructure = JSMIMEType::createStructure(vm, globalObject, prototype); + + init.setPrototype(prototype); + init.setStructure(instanceStructure); + init.setConstructor(constructor); +} + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSMIMEType.h b/src/bun.js/bindings/webcore/JSMIMEType.h new file mode 100644 index 0000000000..be66f2f64d --- /dev/null +++ b/src/bun.js/bindings/webcore/JSMIMEType.h @@ -0,0 +1,100 @@ +#pragma once + +#include "root.h" +#include "JSDOMWrapper.h" // For JSDOMObject +#include "JSMIMEParams.h" // Need JSMIMEParams +#include +#include +#include +#include +#include + +namespace WebCore { + +class JSMIMEType final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForJSMIMEType.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForJSMIMEType = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForJSMIMEType.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForJSMIMEType = std::forward(space); }); + } + + static JSMIMEType* create(JSC::VM& vm, JSC::Structure* structure, WTF::String type, WTF::String subtype, JSMIMEParams* params); + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + const WTF::String& type() const { return m_type; } + void setType(WTF::String type) { m_type = WTFMove(type); } + + const WTF::String& subtype() const { return m_subtype; } + void setSubtype(WTF::String subtype) { m_subtype = WTFMove(subtype); } + + JSMIMEParams* parameters() const { return m_parameters.get(); } + +private: + JSMIMEType(JSC::VM& vm, JSC::Structure* structure); + void finishCreation(JSC::VM& vm, WTF::String type, WTF::String subtype, JSMIMEParams* params); + + WTF::String m_type; + WTF::String m_subtype; + JSC::WriteBarrier m_parameters; +}; + +class JSMIMETypePrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::ImplementsDefaultHasInstance; + + static JSMIMETypePrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure); + + DECLARE_INFO; + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + return &vm.plainObjectSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); + +private: + JSMIMETypePrototype(JSC::VM& vm, JSC::Structure* structure); + void finishCreation(JSC::VM& vm); +}; + +class JSMIMETypeConstructor final : public JSC::InternalFunction { +public: + using Base = JSC::InternalFunction; + static constexpr unsigned StructureFlags = Base::StructureFlags; + + static JSMIMETypeConstructor* create(JSC::VM& vm, JSC::Structure* structure, JSC::JSObject* prototype); + + DECLARE_INFO; + + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + return &vm.internalFunctionSpace(); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); + +private: + JSMIMETypeConstructor(JSC::VM& vm, JSC::Structure* structure); + void finishCreation(JSC::VM& vm, JSC::JSObject* prototype); +}; + +// Function to setup the structures lazily +void setupJSMIMETypeClassStructure(JSC::LazyClassStructure::Initializer&); + +} // namespace WebCore \ No newline at end of file diff --git a/src/highway.zig b/src/highway.zig index 7a7570a075..c2ee1132b8 100644 --- a/src/highway.zig +++ b/src/highway.zig @@ -198,8 +198,8 @@ pub fn indexOfNeedsEscapeForJavaScriptString(slice: string, quote_char: u8) ?u32 if (comptime Environment.isDebug) { const haystack_char = slice[result]; - if (!(haystack_char > 127 or haystack_char < 0x20 or haystack_char == '\\' or haystack_char == quote_char or haystack_char == '$' or haystack_char == '\r' or haystack_char == '\n')) { - @panic("Invalid character found in indexOfNeedsEscapeForJavaScriptString"); + if (!(haystack_char >= 127 or haystack_char < 0x20 or haystack_char == '\\' or haystack_char == quote_char or haystack_char == '$' or haystack_char == '\r' or haystack_char == '\n')) { + std.debug.panic("Invalid character found in indexOfNeedsEscapeForJavaScriptString: U+{x}. Full string: '{'}'", .{ haystack_char, std.zig.fmtEscapes(slice) }); } } diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index a692f11793..89a2dc9191 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -767,6 +767,7 @@ declare function $ERR_HTTP2_OUT_OF_STREAMS(): Error; declare function $ERR_HTTP_BODY_NOT_ALLOWED(): Error; declare function $ERR_HTTP_SOCKET_ASSIGNED(): Error; declare function $ERR_DIR_CLOSED(): Error; +declare function $ERR_INVALID_MIME_SYNTAX(production: string, str: string, invalidIndex: number | -1): TypeError; /** * Convert a function to a class-like object. diff --git a/src/js/internal/util/mime.ts b/src/js/internal/util/mime.ts new file mode 100644 index 0000000000..f8a3fa65fc --- /dev/null +++ b/src/js/internal/util/mime.ts @@ -0,0 +1,6 @@ +const { MIMEParams, MIMEType } = $cpp("JSMIMEParams.cpp", "createJSMIMEBinding"); + +export default { + MIMEParams, + MIMEType, +}; diff --git a/src/js/node/util.ts b/src/js/node/util.ts index bb7310acb7..470f2ca6b6 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -4,6 +4,7 @@ const types = require("node:util/types"); const utl = require("internal/util/inspect"); const { promisify } = require("internal/promisify"); const { validateString, validateOneOf } = require("internal/validators"); +const { MIMEType, MIMEParams } = require("internal/util/mime"); const internalErrorName = $newZigFunction("node_util_binding.zig", "internalErrorName", 1); const parseEnv = $newZigFunction("node_util_binding.zig", "parseEnv", 1); @@ -351,8 +352,8 @@ cjs_exports = { parseArgs, TextDecoder, TextEncoder, - // MIMEType, - // MIMEParams, + MIMEType, + MIMEParams, // Deprecated in Node.js 22, removed in 23 isArray: $isArray, diff --git a/test/js/node/test/parallel/test-mime-api.js b/test/js/node/test/parallel/test-mime-api.js new file mode 100644 index 0000000000..af9b87e887 --- /dev/null +++ b/test/js/node/test/parallel/test-mime-api.js @@ -0,0 +1,180 @@ +// Flags: --expose-internals +'use strict'; + +require('../common'); +const assert = require('assert'); +const { MIMEType, MIMEParams } = require('util'); + +const WHITESPACES = '\t\n\f\r '; +const NOT_HTTP_TOKEN_CODE_POINT = ','; +const NOT_HTTP_QUOTED_STRING_CODE_POINT = '\n'; + +const mime = new MIMEType('application/ecmascript; '); +const mime_descriptors = Object.getOwnPropertyDescriptors(mime); +const mime_proto = Object.getPrototypeOf(mime); +const mime_impersonator = { __proto__: mime_proto }; +for (const key of Object.keys(mime_descriptors)) { + const descriptor = mime_descriptors[key]; + if (descriptor.get) { + assert.throws(descriptor.get.call(mime_impersonator), /Invalid receiver/i); + } + if (descriptor.set) { + assert.throws(descriptor.set.call(mime_impersonator, 'x'), /Invalid receiver/i); + } +} + + +assert.strictEqual( + JSON.stringify(mime), + JSON.stringify('application/ecmascript')); +assert.strictEqual(`${mime}`, 'application/ecmascript'); +assert.strictEqual(mime.essence, 'application/ecmascript'); +assert.strictEqual(mime.type, 'application'); +assert.strictEqual(mime.subtype, 'ecmascript'); +assert.ok(mime.params); +assert.deepStrictEqual([], [...mime.params]); +assert.strictEqual(mime.params.has('not found'), false); +assert.strictEqual(mime.params.get('not found'), null); +assert.strictEqual(mime.params.delete('not found'), undefined); + + +mime.type = 'text'; +assert.strictEqual(mime.type, 'text'); +assert.strictEqual(JSON.stringify(mime), JSON.stringify('text/ecmascript')); +assert.strictEqual(`${mime}`, 'text/ecmascript'); +assert.strictEqual(mime.essence, 'text/ecmascript'); + +assert.throws(() => { + mime.type = `${WHITESPACES}text`; +}, /ERR_INVALID_MIME_SYNTAX/); + +assert.throws(() => mime.type = '', /type/i); +assert.throws(() => mime.type = '/', /type/i); +assert.throws(() => mime.type = 'x/', /type/i); +assert.throws(() => mime.type = '/x', /type/i); +assert.throws(() => mime.type = NOT_HTTP_TOKEN_CODE_POINT, /type/i); +assert.throws(() => mime.type = `${NOT_HTTP_TOKEN_CODE_POINT}/`, /type/i); +assert.throws(() => mime.type = `/${NOT_HTTP_TOKEN_CODE_POINT}`, /type/i); + + +mime.subtype = 'javascript'; +assert.strictEqual(mime.type, 'text'); +assert.strictEqual(JSON.stringify(mime), JSON.stringify('text/javascript')); +assert.strictEqual(`${mime}`, 'text/javascript'); +assert.strictEqual(mime.essence, 'text/javascript'); +assert.strictEqual(`${mime.params}`, ''); +assert.strictEqual(`${new MIMEParams()}`, ''); +assert.strictEqual(`${new MIMEParams(mime.params)}`, ''); +assert.strictEqual(`${new MIMEParams(`${mime.params}`)}`, ''); + +assert.throws(() => { + mime.subtype = `javascript${WHITESPACES}`; +}, /ERR_INVALID_MIME_SYNTAX/); + +assert.throws(() => mime.subtype = '', /subtype/i); +assert.throws(() => mime.subtype = ';', /subtype/i); +assert.throws(() => mime.subtype = 'x;', /subtype/i); +assert.throws(() => mime.subtype = ';x', /subtype/i); +assert.throws(() => mime.subtype = NOT_HTTP_TOKEN_CODE_POINT, /subtype/i); +assert.throws( + () => mime.subtype = `${NOT_HTTP_TOKEN_CODE_POINT};`, + /subtype/i); +assert.throws( + () => mime.subtype = `;${NOT_HTTP_TOKEN_CODE_POINT}`, + /subtype/i); + + +const params = mime.params; +params.set('charset', 'utf-8'); +assert.strictEqual(params.has('charset'), true); +assert.strictEqual(params.get('charset'), 'utf-8'); +{ + // these tests are added by bun + assert.strictEqual(params.get("CHARSET"), null); // case sensitive + const mime2 = new MIMEType('text/javascript;CHARSET=UTF-8;abc=;def;ghi'); + assert.strictEqual(mime2.params.get("CHARSET"), null); + assert.strictEqual(mime2.params.get("charset"), "UTF-8"); // converted to lowercase on parsing + assert.strictEqual(mime2.params.has("CHARSET"), false); + assert.strictEqual(mime2.params.has("charset"), true); + assert.strictEqual(mime2.params.has("abc"), false); + assert.strictEqual(mime2.params.has("def"), false); + assert.strictEqual(mime2.params.has("ghi"), false); + assert.strictEqual(mime2.params.get("abc"), null); + assert.strictEqual(mime2.params.get("def"), null); + assert.strictEqual(mime2.params.get("ghi"), null); + mime2.params.set("CHARSET", "UTF-8"); + assert.strictEqual(mime2.params.get("CHARSET"), "UTF-8"); // not converted to lowercase on set + assert.strictEqual(mime2.params.has("CHARSET"), true); + assert.strictEqual(mime2.params.get("charset"), "UTF-8"); + assert.strictEqual(mime2.params.has("charset"), true); +} +assert.deepStrictEqual([...params], [['charset', 'utf-8']]); +assert.strictEqual( + JSON.stringify(mime), + JSON.stringify('text/javascript;charset=utf-8')); +assert.strictEqual(`${mime}`, 'text/javascript;charset=utf-8'); +assert.strictEqual(mime.essence, 'text/javascript'); +assert.strictEqual(`${mime.params}`, 'charset=utf-8'); +assert.strictEqual(`${new MIMEParams(mime.params)}`, ''); +assert.strictEqual(`${new MIMEParams(`${mime.params}`)}`, ''); + +params.set('goal', 'module'); +assert.strictEqual(params.has('goal'), true); +assert.strictEqual(params.get('goal'), 'module'); +assert.deepStrictEqual([...params], [['charset', 'utf-8'], ['goal', 'module']]); +assert.strictEqual( + JSON.stringify(mime), + JSON.stringify('text/javascript;charset=utf-8;goal=module')); +assert.strictEqual(`${mime}`, 'text/javascript;charset=utf-8;goal=module'); +assert.strictEqual(mime.essence, 'text/javascript'); +assert.strictEqual(`${mime.params}`, 'charset=utf-8;goal=module'); +assert.strictEqual(`${new MIMEParams(mime.params)}`, ''); +assert.strictEqual(`${new MIMEParams(`${mime.params}`)}`, ''); + +assert.throws(() => { + params.set(`${WHITESPACES}goal`, 'module'); +}, /ERR_INVALID_MIME_SYNTAX/); + +params.set('charset', 'iso-8859-1'); +assert.strictEqual(params.has('charset'), true); +assert.strictEqual(params.get('charset'), 'iso-8859-1'); +assert.deepStrictEqual( + [...params], + [['charset', 'iso-8859-1'], ['goal', 'module']]); +assert.strictEqual( + JSON.stringify(mime), + JSON.stringify('text/javascript;charset=iso-8859-1;goal=module')); +assert.strictEqual(`${mime}`, 'text/javascript;charset=iso-8859-1;goal=module'); +assert.strictEqual(mime.essence, 'text/javascript'); + +params.delete('charset'); +assert.strictEqual(params.has('charset'), false); +assert.strictEqual(params.get('charset'), null); +assert.deepStrictEqual([...params], [['goal', 'module']]); +assert.strictEqual( + JSON.stringify(mime), + JSON.stringify('text/javascript;goal=module')); +assert.strictEqual(`${mime}`, 'text/javascript;goal=module'); +assert.strictEqual(mime.essence, 'text/javascript'); + +params.set('x', ''); +assert.strictEqual(params.has('x'), true); +assert.strictEqual(params.get('x'), ''); +assert.deepStrictEqual([...params], [['goal', 'module'], ['x', '']]); +assert.strictEqual( + JSON.stringify(mime), + JSON.stringify('text/javascript;goal=module;x=""')); +assert.strictEqual(`${mime}`, 'text/javascript;goal=module;x=""'); +assert.strictEqual(mime.essence, 'text/javascript'); + +assert.throws(() => params.set('', 'x'), /parameter name/i); +assert.throws(() => params.set('=', 'x'), /parameter name/i); +assert.throws(() => params.set('x=', 'x'), /parameter name/i); +assert.throws(() => params.set('=x', 'x'), /parameter name/i); +assert.throws(() => params.set(`${NOT_HTTP_TOKEN_CODE_POINT}=`, 'x'), /parameter name/i); +assert.throws(() => params.set(`${NOT_HTTP_TOKEN_CODE_POINT}x`, 'x'), /parameter name/i); +assert.throws(() => params.set(`x${NOT_HTTP_TOKEN_CODE_POINT}`, 'x'), /parameter name/i); + +assert.throws(() => params.set('x', `${NOT_HTTP_QUOTED_STRING_CODE_POINT};`), /parameter value/i); +assert.throws(() => params.set('x', `${NOT_HTTP_QUOTED_STRING_CODE_POINT}x`), /parameter value/i); +assert.throws(() => params.set('x', `x${NOT_HTTP_QUOTED_STRING_CODE_POINT}`), /parameter value/i); diff --git a/test/js/node/test/parallel/test-mime-whatwg.js b/test/js/node/test/parallel/test-mime-whatwg.js new file mode 100644 index 0000000000..b61e6d620a --- /dev/null +++ b/test/js/node/test/parallel/test-mime-whatwg.js @@ -0,0 +1,23 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { MIMEType } = require('util'); +const fixtures = require('../common/fixtures'); + +function test(mimes) { + for (const entry of mimes) { + if (typeof entry === 'string') continue; + const { input, output } = entry; + if (output === null) { + assert.throws(() => new MIMEType(input), /ERR_INVALID_MIME_SYNTAX/i); + } else { + const str = `${new MIMEType(input)}`; + assert.strictEqual(str, output); + } + } +} + +// These come from https://github.com/web-platform-tests/wpt/tree/master/mimesniff/mime-types/resources +test(require(fixtures.path('./mime-whatwg.js'))); +test(require(fixtures.path('./mime-whatwg-generated.js'))); diff --git a/test/js/node/util/exact/mime-test.js b/test/js/node/util/exact/mime-test.js new file mode 100644 index 0000000000..09d432a295 --- /dev/null +++ b/test/js/node/util/exact/mime-test.js @@ -0,0 +1,315 @@ +"use strict"; + +const { MIMEType, MIMEParams } = require("util"); + +// Test basic properties and string conversion +console.log("=== BASIC PROPERTIES AND STRING CONVERSION ==="); +const mime1 = new MIMEType("application/ecmascript; "); +console.log(`mime1: ${mime1}`); // application/ecmascript +console.log(`JSON.stringify: ${JSON.stringify(mime1)}`); // "application/ecmascript" +console.log(`essence: ${mime1.essence}`); // application/ecmascript +console.log(`type: ${mime1.type}`); // application +console.log(`subtype: ${mime1.subtype}`); // ecmascript +console.log(`params empty: ${[...mime1.params].length === 0}`); // true +console.log(`params.has("not found"): ${mime1.params.has("not found")}`); // false +console.log(`params.get("not found"): ${mime1.params.get("not found") === null}`); // true + +// Test type property manipulation +console.log("\n=== TYPE PROPERTY MANIPULATION ==="); +const mime2 = new MIMEType("application/javascript"); +console.log(`Original: ${mime2}`); // application/javascript +mime2.type = "text"; +console.log(`After type change: ${mime2}`); // text/javascript +console.log(`essence: ${mime2.essence}`); // text/javascript + +try { + mime2.type = ""; + console.log("Should throw error for empty type but didn't"); +} catch (e) { + console.log("Error on empty type as expected"); +} + +try { + mime2.type = ","; + console.log("Should throw error for invalid type but didn't"); +} catch (e) { + console.log("Error on invalid type as expected"); +} + +// Test subtype property manipulation +console.log("\n=== SUBTYPE PROPERTY MANIPULATION ==="); +const mime3 = new MIMEType("text/plain"); +console.log(`Original: ${mime3}`); // text/plain +mime3.subtype = "javascript"; +console.log(`After subtype change: ${mime3}`); // text/javascript + +try { + mime3.subtype = ""; + console.log("Should throw error for empty subtype but didn't"); +} catch (e) { + console.log("Error on empty subtype as expected"); +} + +try { + mime3.subtype = ","; + console.log("Should throw error for invalid subtype but didn't"); +} catch (e) { + console.log("Error on invalid subtype as expected"); +} + +// Test parameters manipulation +console.log("\n=== PARAMETERS MANIPULATION ==="); +const mime4 = new MIMEType("text/javascript"); +const params = mime4.params; + +// Setting parameters +params.set("charset", "utf-8"); +console.log(`params.has("charset"): ${params.has("charset")}`); // true +console.log(`params.get("charset"): ${params.get("charset")}`); // utf-8 +console.log(`params entries length: ${[...params].length}`); // 1 +console.log(`mime with charset: ${mime4}`); // text/javascript;charset=utf-8 + +// Multiple parameters +params.set("goal", "module"); +console.log(`params.has("goal"): ${params.has("goal")}`); // true +console.log(`params.get("goal"): ${params.get("goal")}`); // module +console.log(`params entries length: ${[...params].length}`); // 2 +console.log(`mime with multiple params: ${mime4}`); // text/javascript;charset=utf-8;goal=module + +// Updating a parameter +params.set("charset", "iso-8859-1"); +console.log(`updated charset: ${params.get("charset")}`); // iso-8859-1 +console.log(`mime with updated charset: ${mime4}`); // text/javascript;charset=iso-8859-1;goal=module + +// Deleting a parameter +params.delete("charset"); +console.log(`params.has("charset") after delete: ${params.has("charset")}`); // false +console.log(`params.get("charset") after delete: ${params.get("charset") === null}`); // true +console.log(`params entries length after delete: ${[...params].length}`); // 1 +console.log(`mime after param delete: ${mime4}`); // text/javascript;goal=module + +// Empty parameter value +params.set("x", ""); +console.log(`params.has("x"): ${params.has("x")}`); // true +console.log(`params.get("x"): ${params.get("x") === "" ? "empty string" : params.get("x")}`); // empty string +console.log(`mime with empty param: ${mime4}`); // text/javascript;goal=module;x="" + +// Test parameter case sensitivity +console.log("\n=== PARAMETER CASE SENSITIVITY ==="); +const mime5 = new MIMEType("text/javascript;CHARSET=UTF-8;abc=;def;ghi"); +console.log(`mime5: ${mime5}`); // text/javascript;charset=UTF-8 +console.log(`mime5.params.get("CHARSET"): ${mime5.params.get("CHARSET") === null}`); // true (null) +console.log(`mime5.params.get("charset"): ${mime5.params.get("charset")}`); // UTF-8 +console.log(`mime5.params.has("CHARSET"): ${mime5.params.has("CHARSET")}`); // false +console.log(`mime5.params.has("charset"): ${mime5.params.has("charset")}`); // true +console.log(`mime5.params.has("abc"): ${mime5.params.has("abc")}`); // false (invalid param) +console.log(`mime5.params.has("def"): ${mime5.params.has("def")}`); // false (invalid param) + +mime5.params.set("CHARSET", "UTF-8"); +console.log(`mime5.params.get("CHARSET") after set: ${mime5.params.get("CHARSET")}`); // UTF-8 +console.log(`mime5.params.has("CHARSET") after set: ${mime5.params.has("CHARSET")}`); // true + +// Test quoted parameter values +console.log("\n=== QUOTED PARAMETER VALUES ==="); +const mime6 = new MIMEType('text/plain;charset="utf-8"'); +console.log(`mime6: ${mime6}`); // text/plain;charset=utf-8 +console.log(`mime6.params.get("charset"): ${mime6.params.get("charset")}`); // utf-8 + +// Setting parameter that requires quoting +params.set("filename", "file with spaces.txt"); +console.log(`mime with filename: ${mime4}`); // Should have quotes around the value + +// Test invalid parameters +console.log("\n=== INVALID PARAMETERS ==="); +try { + params.set("", "x"); + console.log("Should throw error for empty param name but didn't"); +} catch (e) { + console.log("Error on empty param name as expected"); +} + +try { + params.set("x=", "x"); + console.log("Should throw error for invalid param name but didn't"); +} catch (e) { + console.log("Error on invalid param name as expected"); +} + +try { + params.set("x", "\n"); + console.log("Should throw error for invalid param value but didn't"); +} catch (e) { + console.log("Error on invalid param value as expected"); +} + +// Test params iteration +console.log("\n=== PARAMS ITERATION ==="); +const mime7 = new MIMEType("text/plain;charset=utf-8;format=flowed"); +console.log("Iterating params.entries():"); +for (const [key, value] of mime7.params.entries()) { + console.log(` ${key}: ${value}`); +} + +console.log("Iterating params.keys():"); +for (const key of mime7.params.keys()) { + console.log(` ${key}`); +} + +console.log("Iterating params.values():"); +for (const value of mime7.params.values()) { + console.log(` ${value}`); +} + +console.log("Iterating params directly:"); +for (const entry of mime7.params) { + console.log(` ${entry[0]}: ${entry[1]}`); +} + +// Test parsing edge cases +console.log("\n=== PARSING EDGE CASES ==="); +const mime8 = new MIMEType("text/plain; charset=utf-8; goal=module; empty="); +console.log(`mime8: ${mime8}`); // text/plain;charset=utf-8;goal=module +console.log(`Has empty param: ${mime8.params.has("empty")}`); // false (invalid parameter) + +const mime9 = new MIMEType('text/plain; charset="utf\\-8"'); +console.log(`mime9: ${mime9}`); // text/plain;charset="utf-8" +console.log(`mime9 charset: ${mime9.params.get("charset")}`); // utf-8 + +// Test toString() and toJSON() +console.log("\n=== TO STRING AND TO JSON ==="); +const mime10 = new MIMEType("text/plain;charset=utf-8"); +console.log(`toString(): ${mime10.toString()}`); // text/plain;charset=utf-8 +console.log(`toJSON(): ${mime10.toJSON()}`); // text/plain;charset=utf-8 + +console.log(`params toString(): ${mime10.params.toString()}`); // charset=utf-8 +console.log(`params toJSON(): ${mime10.params.toJSON()}`); // charset=utf-8 + +// Basic MIMEParams tests +console.log("=== BASIC MIMEPARAMS OPERATIONS ==="); +const params2 = new MIMEParams(); +console.log(`New params empty: ${[...params2].length === 0}`); // true + +// Set and get operations +params2.set("charset", "utf-8"); +console.log(`params.has("charset"): ${params2.has("charset")}`); // true +console.log(`params.get("charset"): ${params2.get("charset")}`); // utf-8 +console.log(`params entries length: ${[...params2].length}`); // 1 +console.log(`params toString(): ${params2.toString()}`); // charset=utf-8 + +// Case sensitivity +console.log(`\n=== CASE SENSITIVITY ===`); +console.log(`params.has("CHARSET"): ${params2.has("CHARSET")}`); // false +console.log(`params.get("CHARSET"): ${params2.get("CHARSET") === null}`); // true +params2.set("CHARSET", "iso-8859-1"); +console.log(`After setting CHARSET, params.has("CHARSET"): ${params2.has("CHARSET")}`); // true +console.log(`After setting CHARSET, params.get("CHARSET"): ${params2.get("CHARSET")}`); // iso-8859-1 +console.log(`params.has("charset"): ${params2.has("charset")}`); // true, original still exists +console.log(`params.get("charset"): ${params2.get("charset")}`); // utf-8 +console.log(`params entries length: ${[...params2].length}`); // 2 +console.log(`params toString(): ${params2.toString()}`); // charset=utf-8;CHARSET=iso-8859-1 + +// Delete operation +console.log(`\n=== DELETE OPERATION ===`); +params2.delete("charset"); +console.log(`After delete, params.has("charset"): ${params2.has("charset")}`); // false +console.log(`After delete, params.get("charset"): ${params2.get("charset") === null}`); // true +console.log(`params.has("CHARSET"): ${params2.has("CHARSET")}`); // true, other case still exists +console.log(`params entries length: ${[...params2].length}`); // 1 +console.log(`params toString(): ${params2.toString()}`); // CHARSET=iso-8859-1 + +// Multiple parameters +console.log(`\n=== MULTIPLE PARAMETERS ===`); +params2.set("format", "flowed"); +params2.set("delsp", "yes"); +console.log(`params entries length: ${[...params2].length}`); // 3 +console.log(`params toString(): ${params2.toString()}`); // CHARSET=iso-8859-1;format=flowed;delsp=yes + +// Parameter values requiring quoting +console.log(`\n=== QUOTED VALUES ===`); +params2.set("filename", "file with spaces.txt"); +console.log(`params.get("filename"): ${params2.get("filename")}`); // file with spaces.txt +console.log(`params toString(): ${params2.toString()}`); // should contain quoted filename + +// Empty parameter values +console.log(`\n=== EMPTY VALUES ===`); +params2.set("empty", ""); +console.log(`params.has("empty"): ${params2.has("empty")}`); // true +console.log(`params.get("empty"): ${params2.get("empty") === "" ? "empty string" : params2.get("empty")}`); // empty string +console.log(`params toString() with empty value: ${params2.toString()}`); // includes empty="" + +// Characters requiring escaping in quoted strings +console.log(`\n=== ESCAPE SEQUENCES IN QUOTED VALUES ===`); +params2.set("path", "C:\\Program Files\\App"); +console.log(`params.get("path"): ${params2.get("path")}`); // C:\Program Files\App +console.log(`params toString() with backslashes: ${params2.toString()}`); // should escape backslashes + +// Special characters +console.log(`\n=== SPECIAL CHARACTERS ===`); +params2.set("test", "!#$%&'*+-.^_`|~"); +console.log(`params.get("test"): ${params2.get("test")}`); // !#$%&'*+-.^_`|~ +console.log(`params toString() with special chars: ${params2.toString()}`); // should not quote these + +// Error cases +console.log(`\n=== ERROR CASES ===`); +try { + params2.set("", "value"); + console.log("Should throw error for empty name but didn't"); +} catch (e) { + console.log(`Empty name error: ${e.name}`); +} + +try { + params2.set("invalid name", "value"); + console.log("Should throw error for invalid name but didn't"); +} catch (e) { + console.log(`Invalid name error: ${e.name}`); +} + +try { + params2.set("name", "\0"); + console.log("Should throw error for invalid value but didn't"); +} catch (e) { + console.log(`Invalid value error: ${e.name}`); +} + +// Iteration methods +console.log(`\n=== ITERATION METHODS ===`); +console.log(`Keys:`); +for (const key of params2.keys()) { + console.log(` ${key}`); +} + +console.log(`Values:`); +for (const value of params2.values()) { + console.log(` ${value}`); +} + +console.log(`Entries:`); +for (const [key, value] of params2.entries()) { + console.log(` ${key}: ${value}`); +} + +console.log(`Direct iteration:`); +for (const [key, value] of params2) { + console.log(` ${key}: ${value}`); +} + +// toJSON method +console.log(`\n=== JSON SERIALIZATION ===`); +console.log(`params.toJSON(): ${params2.toJSON()}`); +console.log(`JSON.stringify(params): ${JSON.stringify(params2)}`); + +// Clone and modify test +console.log(`\n=== CLONE AND MODIFY ===`); +const original = new MIMEParams(); +original.set("charset", "utf-8"); +original.set("boundary", "boundary"); + +const clone = new MIMEParams(); +for (const [key, value] of original) { + clone.set(key, value); +} +clone.set("charset", "iso-8859-1"); + +console.log(`Original params: ${original.toString()}`); +console.log(`Cloned params: ${clone.toString()}`); diff --git a/test/js/node/util/mime-api.test.ts b/test/js/node/util/mime-api.test.ts new file mode 100644 index 0000000000..a1c2c3397c --- /dev/null +++ b/test/js/node/util/mime-api.test.ts @@ -0,0 +1,409 @@ +import { describe, test, expect } from "bun:test"; +import { bunExe } from "harness"; +import { MIMEType, MIMEParams } from "util"; + +describe("MIME API", () => { + const WHITESPACES = "\t\n\f\r "; + const NOT_HTTP_TOKEN_CODE_POINT = ","; + const NOT_HTTP_QUOTED_STRING_CODE_POINT = "\n"; + + test("class instance integrity", () => { + const mime = new MIMEType("application/ecmascript; "); + const mime_descriptors = Object.getOwnPropertyDescriptors(mime); + const mime_proto = Object.getPrototypeOf(mime); + const mime_impersonator = { __proto__: mime_proto }; + + for (const key of Object.keys(mime_descriptors)) { + const descriptor = mime_descriptors[key]; + if (descriptor.get) { + const getter = descriptor.get; + expect(() => getter.call(mime_impersonator)).toThrow(/invalid receiver/i); + } + if (descriptor.set) { + const setter = descriptor.set; + expect(() => setter.call(mime_impersonator, "x")).toThrow(/invalid receiver/i); + } + } + }); + + test("basic properties and string conversion", () => { + const mime = new MIMEType("application/ecmascript; "); + + expect(JSON.stringify(mime)).toBe(JSON.stringify("application/ecmascript")); + expect(`${mime}`).toBe("application/ecmascript"); + expect(mime.essence).toBe("application/ecmascript"); + expect(mime.type).toBe("application"); + expect(mime.subtype).toBe("ecmascript"); + expect(mime.params).toBeDefined(); + expect([...mime.params]).toEqual([]); + expect(mime.params.has("not found")).toBe(false); + expect(mime.params.get("not found")).toBe(null); + expect(mime.params.delete("not found")).toBe(undefined); + }); + + test("type property manipulation", () => { + const mime = new MIMEType("application/ecmascript; "); + + mime.type = "text"; + expect(mime.type).toBe("text"); + expect(JSON.stringify(mime)).toBe(JSON.stringify("text/ecmascript")); + expect(`${mime}`).toBe("text/ecmascript"); + expect(mime.essence).toBe("text/ecmascript"); + + expect(() => { + mime.type = `${WHITESPACES}text`; + }).toThrow(/The MIME syntax for a type in/); + + expect(() => { + mime.type = ""; + }).toThrow(/type/i); + expect(() => { + mime.type = "/"; + }).toThrow(/type/i); + expect(() => { + mime.type = "x/"; + }).toThrow(/type/i); + expect(() => { + mime.type = "/x"; + }).toThrow(/type/i); + expect(() => { + mime.type = NOT_HTTP_TOKEN_CODE_POINT; + }).toThrow(/type/i); + expect(() => { + mime.type = `${NOT_HTTP_TOKEN_CODE_POINT}/`; + }).toThrow(/type/i); + expect(() => { + mime.type = `/${NOT_HTTP_TOKEN_CODE_POINT}`; + }).toThrow(/type/i); + }); + + test("subtype property manipulation", () => { + const mime = new MIMEType("application/ecmascript; "); + mime.type = "text"; + + mime.subtype = "javascript"; + expect(mime.type).toBe("text"); + expect(JSON.stringify(mime)).toBe(JSON.stringify("text/javascript")); + expect(`${mime}`).toBe("text/javascript"); + expect(mime.essence).toBe("text/javascript"); + expect(`${mime.params}`).toBe(""); + expect(`${new MIMEParams()}`).toBe(""); + // @ts-expect-error + expect(`${new MIMEParams(mime.params)}`).toBe(""); + // @ts-expect-error + expect(`${new MIMEParams(`${mime.params}`)}`).toBe(""); + + expect(() => { + mime.subtype = `javascript${WHITESPACES}`; + }).toThrow(/The MIME syntax for a subtype in/); + + expect(() => { + mime.subtype = ""; + }).toThrow(/subtype/i); + expect(() => { + mime.subtype = ";"; + }).toThrow(/subtype/i); + expect(() => { + mime.subtype = "x;"; + }).toThrow(/subtype/i); + expect(() => { + mime.subtype = ";x"; + }).toThrow(/subtype/i); + expect(() => { + mime.subtype = NOT_HTTP_TOKEN_CODE_POINT; + }).toThrow(/subtype/i); + expect(() => { + mime.subtype = `${NOT_HTTP_TOKEN_CODE_POINT};`; + }).toThrow(/subtype/i); + expect(() => { + mime.subtype = `;${NOT_HTTP_TOKEN_CODE_POINT}`; + }).toThrow(/subtype/i); + }); + + test("parameters manipulation", () => { + const mime = new MIMEType("application/ecmascript; "); + mime.type = "text"; + mime.subtype = "javascript"; + + const params = mime.params; + + // Setting parameters + params.set("charset", "utf-8"); + expect(params.has("charset")).toBe(true); + expect(params.get("charset")).toBe("utf-8"); + expect([...params]).toEqual([["charset", "utf-8"]]); + expect(JSON.stringify(mime)).toBe(JSON.stringify("text/javascript;charset=utf-8")); + expect(`${mime}`).toBe("text/javascript;charset=utf-8"); + expect(mime.essence).toBe("text/javascript"); + expect(`${mime.params}`).toBe("charset=utf-8"); + // @ts-expect-error + expect(`${new MIMEParams(mime.params)}`).toBe(""); + // @ts-expect-error + expect(`${new MIMEParams(`${mime.params}`)}`).toBe(""); + + // Multiple parameters + params.set("goal", "module"); + expect(params.has("goal")).toBe(true); + expect(params.get("goal")).toBe("module"); + expect([...params]).toEqual([ + ["charset", "utf-8"], + ["goal", "module"], + ]); + expect(JSON.stringify(mime)).toBe(JSON.stringify("text/javascript;charset=utf-8;goal=module")); + expect(`${mime}`).toBe("text/javascript;charset=utf-8;goal=module"); + expect(mime.essence).toBe("text/javascript"); + expect(`${mime.params}`).toBe("charset=utf-8;goal=module"); + + // Invalid parameter name + expect(() => { + params.set(`${WHITESPACES}goal`, "module"); + }).toThrow(/The MIME syntax for a parameter name in/); + + // Updating a parameter + params.set("charset", "iso-8859-1"); + expect(params.has("charset")).toBe(true); + expect(params.get("charset")).toBe("iso-8859-1"); + expect([...params]).toEqual([ + ["charset", "iso-8859-1"], + ["goal", "module"], + ]); + expect(JSON.stringify(mime)).toBe(JSON.stringify("text/javascript;charset=iso-8859-1;goal=module")); + expect(`${mime}`).toBe("text/javascript;charset=iso-8859-1;goal=module"); + expect(mime.essence).toBe("text/javascript"); + + // Deleting a parameter + params.delete("charset"); + expect(params.has("charset")).toBe(false); + expect(params.get("charset")).toBe(null); + expect([...params]).toEqual([["goal", "module"]]); + expect(JSON.stringify(mime)).toBe(JSON.stringify("text/javascript;goal=module")); + expect(`${mime}`).toBe("text/javascript;goal=module"); + expect(mime.essence).toBe("text/javascript"); + + // Empty parameter value + params.set("x", ""); + expect(params.has("x")).toBe(true); + expect(params.get("x")).toBe(""); + expect([...params]).toEqual([ + ["goal", "module"], + ["x", ""], + ]); + expect(JSON.stringify(mime)).toBe(JSON.stringify('text/javascript;goal=module;x=""')); + expect(`${mime}`).toBe('text/javascript;goal=module;x=""'); + expect(mime.essence).toBe("text/javascript"); + }); + + test("invalid parameter names", () => { + const mime = new MIMEType("text/javascript"); + const params = mime.params; + + expect(() => params.set("", "x")).toThrow(/parameter name/i); + expect(() => params.set("=", "x")).toThrow(/parameter name/i); + expect(() => params.set("x=", "x")).toThrow(/parameter name/i); + expect(() => params.set("=x", "x")).toThrow(/parameter name/i); + expect(() => params.set(`${NOT_HTTP_TOKEN_CODE_POINT}=`, "x")).toThrow(/parameter name/i); + expect(() => params.set(`${NOT_HTTP_TOKEN_CODE_POINT}x`, "x")).toThrow(/parameter name/i); + expect(() => params.set(`x${NOT_HTTP_TOKEN_CODE_POINT}`, "x")).toThrow(/parameter name/i); + }); + + test("invalid parameter values", () => { + const mime = new MIMEType("text/javascript"); + const params = mime.params; + + expect(() => params.set("x", `${NOT_HTTP_QUOTED_STRING_CODE_POINT};`)).toThrow(/parameter value/i); + expect(() => params.set("x", `${NOT_HTTP_QUOTED_STRING_CODE_POINT}x`)).toThrow(/parameter value/i); + expect(() => params.set("x", `x${NOT_HTTP_QUOTED_STRING_CODE_POINT}`)).toThrow(/parameter value/i); + }); +}); + +test("Exact match with node", () => { + const result = Bun.spawnSync({ + cmd: [bunExe(), import.meta.dir + "/exact/mime-test.js"], + }); + + expect(result.stderr.toString("utf-8")).toBe(""); + expect(result.exitCode).toBe(0); + // exact output on v23.4.0 + expect(result.stdout.toString("utf-8")).toMatchInlineSnapshot(` + "=== BASIC PROPERTIES AND STRING CONVERSION === + mime1: application/ecmascript + JSON.stringify: "application/ecmascript" + essence: application/ecmascript + type: application + subtype: ecmascript + params empty: true + params.has("not found"): false + params.get("not found"): true + + === TYPE PROPERTY MANIPULATION === + Original: application/javascript + After type change: text/javascript + essence: text/javascript + Error on empty type as expected + Error on invalid type as expected + + === SUBTYPE PROPERTY MANIPULATION === + Original: text/plain + After subtype change: text/javascript + Error on empty subtype as expected + Error on invalid subtype as expected + + === PARAMETERS MANIPULATION === + params.has("charset"): true + params.get("charset"): utf-8 + params entries length: 1 + mime with charset: text/javascript;charset=utf-8 + params.has("goal"): true + params.get("goal"): module + params entries length: 2 + mime with multiple params: text/javascript;charset=utf-8;goal=module + updated charset: iso-8859-1 + mime with updated charset: text/javascript;charset=iso-8859-1;goal=module + params.has("charset") after delete: false + params.get("charset") after delete: true + params entries length after delete: 1 + mime after param delete: text/javascript;goal=module + params.has("x"): true + params.get("x"): empty string + mime with empty param: text/javascript;goal=module;x="" + + === PARAMETER CASE SENSITIVITY === + mime5: text/javascript;charset=UTF-8 + mime5.params.get("CHARSET"): true + mime5.params.get("charset"): UTF-8 + mime5.params.has("CHARSET"): false + mime5.params.has("charset"): true + mime5.params.has("abc"): false + mime5.params.has("def"): false + mime5.params.get("CHARSET") after set: UTF-8 + mime5.params.has("CHARSET") after set: true + + === QUOTED PARAMETER VALUES === + mime6: text/plain;charset=utf-8 + mime6.params.get("charset"): utf-8 + mime with filename: text/javascript;goal=module;x="";filename="file with spaces.txt" + + === INVALID PARAMETERS === + Error on empty param name as expected + Error on invalid param name as expected + Error on invalid param value as expected + + === PARAMS ITERATION === + Iterating params.entries(): + charset: utf-8 + format: flowed + Iterating params.keys(): + charset + format + Iterating params.values(): + utf-8 + flowed + Iterating params directly: + charset: utf-8 + format: flowed + + === PARSING EDGE CASES === + mime8: text/plain;charset=utf-8;goal=module + Has empty param: false + mime9: text/plain;charset=utf-8 + mime9 charset: utf-8 + + === TO STRING AND TO JSON === + toString(): text/plain;charset=utf-8 + toJSON(): text/plain;charset=utf-8 + params toString(): charset=utf-8 + params toJSON(): charset=utf-8 + === BASIC MIMEPARAMS OPERATIONS === + New params empty: true + params.has("charset"): true + params.get("charset"): utf-8 + params entries length: 1 + params toString(): charset=utf-8 + + === CASE SENSITIVITY === + params.has("CHARSET"): false + params.get("CHARSET"): true + After setting CHARSET, params.has("CHARSET"): true + After setting CHARSET, params.get("CHARSET"): iso-8859-1 + params.has("charset"): true + params.get("charset"): utf-8 + params entries length: 2 + params toString(): charset=utf-8;CHARSET=iso-8859-1 + + === DELETE OPERATION === + After delete, params.has("charset"): false + After delete, params.get("charset"): true + params.has("CHARSET"): true + params entries length: 1 + params toString(): CHARSET=iso-8859-1 + + === MULTIPLE PARAMETERS === + params entries length: 3 + params toString(): CHARSET=iso-8859-1;format=flowed;delsp=yes + + === QUOTED VALUES === + params.get("filename"): file with spaces.txt + params toString(): CHARSET=iso-8859-1;format=flowed;delsp=yes;filename="file with spaces.txt" + + === EMPTY VALUES === + params.has("empty"): true + params.get("empty"): empty string + params toString() with empty value: CHARSET=iso-8859-1;format=flowed;delsp=yes;filename="file with spaces.txt";empty="" + + === ESCAPE SEQUENCES IN QUOTED VALUES === + params.get("path"): C:\\Program Files\\App + params toString() with backslashes: CHARSET=iso-8859-1;format=flowed;delsp=yes;filename="file with spaces.txt";empty="";path="C:\\\\Program Files\\\\App" + + === SPECIAL CHARACTERS === + params.get("test"): !#$%&'*+-.^_\`|~ + params toString() with special chars: CHARSET=iso-8859-1;format=flowed;delsp=yes;filename="file with spaces.txt";empty="";path="C:\\\\Program Files\\\\App";test=!#$%&'*+-.^_\`|~ + + === ERROR CASES === + Empty name error: TypeError + Invalid name error: TypeError + Invalid value error: TypeError + + === ITERATION METHODS === + Keys: + CHARSET + format + delsp + filename + empty + path + test + Values: + iso-8859-1 + flowed + yes + file with spaces.txt + + C:\\Program Files\\App + !#$%&'*+-.^_\`|~ + Entries: + CHARSET: iso-8859-1 + format: flowed + delsp: yes + filename: file with spaces.txt + empty: + path: C:\\Program Files\\App + test: !#$%&'*+-.^_\`|~ + Direct iteration: + CHARSET: iso-8859-1 + format: flowed + delsp: yes + filename: file with spaces.txt + empty: + path: C:\\Program Files\\App + test: !#$%&'*+-.^_\`|~ + + === JSON SERIALIZATION === + params.toJSON(): CHARSET=iso-8859-1;format=flowed;delsp=yes;filename="file with spaces.txt";empty="";path="C:\\\\Program Files\\\\App";test=!#$%&'*+-.^_\`|~ + JSON.stringify(params): "CHARSET=iso-8859-1;format=flowed;delsp=yes;filename=\\"file with spaces.txt\\";empty=\\"\\";path=\\"C:\\\\\\\\Program Files\\\\\\\\App\\";test=!#$%&'*+-.^_\`|~" + + === CLONE AND MODIFY === + Original params: charset=utf-8;boundary=boundary + Cloned params: charset=iso-8859-1;boundary=boundary + " + `); +});