diff --git a/docs/api/cookie.md b/docs/api/cookie.md new file mode 100644 index 0000000000..a56859895d --- /dev/null +++ b/docs/api/cookie.md @@ -0,0 +1,448 @@ +# Bun.Cookie & Bun.CookieMap + +Bun provides native APIs for working with HTTP cookies through `Bun.Cookie` and `Bun.CookieMap`. These APIs offer fast, easy-to-use methods for parsing, generating, and manipulating cookies in HTTP requests and responses. + +## CookieMap class + +`Bun.CookieMap` provides a Map-like interface for working with collections of cookies. It implements the `Iterable` interface, allowing you to use it with `for...of` loops and other iteration methods. + +```ts +import { CookieMap } from "bun"; + +// Empty cookie map +const cookies = new Bun.CookieMap(); + +// From a cookie string +const cookies1 = new Bun.CookieMap("name=value; foo=bar"); + +// From an object +const cookies2 = new Bun.CookieMap({ + session: "abc123", + theme: "dark", +}); + +// From an array of name/value pairs +const cookies3 = new Bun.CookieMap([ + ["session", "abc123"], + ["theme", "dark"], +]); +``` + +### In HTTP servers + +In Bun's HTTP server, the `cookies` property on the request object is an instance of `CookieMap`: + +```ts +const server = Bun.serve({ + port: 3000, + fetch(req) { + // Access request cookies + const cookies = req.cookies; + + // Get a specific cookie + const sessionCookie = cookies.get("session"); + if (sessionCookie != null) { + console.log(sessionCookie); + } + + // Check if a cookie exists + if (cookies.has("theme")) { + // ... + } + + // Set a cookie, it will be automatically applied to the response + cookies.set("visited", "true"); + + return new Response("Hello"); + }, +}); +``` + +### Methods + +#### `get(name: string): string | null` + +Retrieves a cookie by name. Returns `null` if the cookie doesn't exist. + +```ts +// Get by name +const cookie = cookies.get("session"); + +if (cookie != null) { + console.log(cookie); +} +``` + +#### `has(name: string): boolean` + +Checks if a cookie with the given name exists. + +```ts +// Check if cookie exists +if (cookies.has("session")) { + // Cookie exists +} +``` + +#### `set(name: string, value: string): void` + +#### `set(options: CookieInit): void` + +#### `set(cookie: Cookie): void` + +Adds or updates a cookie in the map. + +```ts +// Set by name and value +cookies.set("session", "abc123"); + +// Set using options object +cookies.set({ + name: "theme", + value: "dark", + maxAge: 3600, + secure: true, +}); + +// Set using Cookie instance +const cookie = new Bun.Cookie("visited", "true"); +cookies.set(cookie); +``` + +#### `delete(name: string): void` + +#### `delete(options: CookieStoreDeleteOptions): void` + +Removes a cookie from the map. When applied to a Response, this adds a cookie with an empty string value and an expiry date in the past. A cookie will only delete succesfully on the browser if the domain and path is the same as it was when the cookie was created. + +```ts +// Delete by name using default domain and path. +cookies.delete("session"); + +// Delete with domain/path options. +cookies.delete({ + name: "session", + domain: "example.com", + path: "/admin", +}); +``` + +#### `toJSON(): Record` + +Converts the cookie map to a serializable format. + +```ts +const json = cookies.toJSON(); +``` + +#### `toSetCookieHeaders(): string[]` + +Returns an array of values for Set-Cookie headers that can be used to apply all cookie changes. + +When using `Bun.serve()`, you don't need to call this method explicitly. Any changes made to the `req.cookies` map are automatically applied to the response headers. This method is primarily useful when working with other HTTP server implementations. + +```js +import { createServer } from "node:http"; +import { CookieMap } from "bun"; + +const server = createServer((req, res) => { + const cookieHeader = req.headers.cookie || ""; + const cookies = new CookieMap(cookieHeader); + + cookies.set("view-count", Number(cookies.get("view-count") || "0") + 1); + cookies.delete("session"); + + res.writeHead(200, { + "Content-Type": "text/plain", + "Set-Cookie": cookies.toSetCookieHeaders(), + }); + res.end(`Found ${cookies.size} cookies`); +}); + +server.listen(3000, () => { + console.log("Server running at http://localhost:3000/"); +}); +``` + +### Iteration + +`CookieMap` provides several methods for iteration: + +```ts +// Iterate over [name, cookie] entries +for (const [name, value] of cookies) { + console.log(`${name}: ${value}`); +} + +// Using entries() +for (const [name, value] of cookies.entries()) { + console.log(`${name}: ${value}`); +} + +// Using keys() +for (const name of cookies.keys()) { + console.log(name); +} + +// Using values() +for (const value of cookies.values()) { + console.log(value); +} + +// Using forEach +cookies.forEach((value, name) => { + console.log(`${name}: ${value}`); +}); +``` + +### Properties + +#### `size: number` + +Returns the number of cookies in the map. + +```ts +console.log(cookies.size); // Number of cookies +``` + +## Cookie class + +`Bun.Cookie` represents an HTTP cookie with its name, value, and attributes. + +```ts +import { Cookie } from "bun"; + +// Create a basic cookie +const cookie = new Bun.Cookie("name", "value"); + +// Create a cookie with options +const secureSessionCookie = new Bun.Cookie("session", "abc123", { + domain: "example.com", + path: "/admin", + expires: new Date(Date.now() + 86400000), // 1 day + httpOnly: true, + secure: true, + sameSite: "strict", +}); + +// Parse from a cookie string +const parsedCookie = new Bun.Cookie("name=value; Path=/; HttpOnly"); + +// Create from an options object +const objCookie = new Bun.Cookie({ + name: "theme", + value: "dark", + maxAge: 3600, + secure: true, +}); +``` + +### Constructors + +```ts +// Basic constructor with name/value +new Bun.Cookie(name: string, value: string); + +// Constructor with name, value, and options +new Bun.Cookie(name: string, value: string, options: CookieInit); + +// Constructor from cookie string +new Bun.Cookie(cookieString: string); + +// Constructor from cookie object +new Bun.Cookie(options: CookieInit); +``` + +### Properties + +```ts +cookie.name; // string - Cookie name +cookie.value; // string - Cookie value +cookie.domain; // string | null - Domain scope (null if not specified) +cookie.path; // string - URL path scope (defaults to "/") +cookie.expires; // number | undefined - Expiration timestamp (ms since epoch) +cookie.secure; // boolean - Require HTTPS +cookie.sameSite; // "strict" | "lax" | "none" - SameSite setting +cookie.partitioned; // boolean - Whether the cookie is partitioned (CHIPS) +cookie.maxAge; // number | undefined - Max age in seconds +cookie.httpOnly; // boolean - Accessible only via HTTP (not JavaScript) +``` + +### Methods + +#### `isExpired(): boolean` + +Checks if the cookie has expired. + +```ts +// Expired cookie (Date in the past) +const expiredCookie = new Bun.Cookie("name", "value", { + expires: new Date(Date.now() - 1000), +}); +console.log(expiredCookie.isExpired()); // true + +// Valid cookie (Using maxAge instead of expires) +const validCookie = new Bun.Cookie("name", "value", { + maxAge: 3600, // 1 hour in seconds +}); +console.log(validCookie.isExpired()); // false + +// Session cookie (no expiration) +const sessionCookie = new Bun.Cookie("name", "value"); +console.log(sessionCookie.isExpired()); // false +``` + +#### `serialize(): string` + +#### `toString(): string` + +Returns a string representation of the cookie suitable for a `Set-Cookie` header. + +```ts +const cookie = new Bun.Cookie("session", "abc123", { + domain: "example.com", + path: "/admin", + expires: new Date(Date.now() + 86400000), + secure: true, + httpOnly: true, + sameSite: "strict", +}); + +console.log(cookie.serialize()); +// => "session=abc123; Domain=example.com; Path=/admin; Expires=Sun, 19 Mar 2025 15:03:26 GMT; Secure; HttpOnly; SameSite=strict" +console.log(cookie.toString()); +// => "session=abc123; Domain=example.com; Path=/admin; Expires=Sun, 19 Mar 2025 15:03:26 GMT; Secure; HttpOnly; SameSite=strict" +``` + +#### `toJSON(): CookieInit` + +Converts the cookie to a plain object suitable for JSON serialization. + +```ts +const cookie = new Bun.Cookie("session", "abc123", { + secure: true, + httpOnly: true, +}); + +const json = cookie.toJSON(); +// => { +// name: "session", +// value: "abc123", +// path: "/", +// secure: true, +// httpOnly: true, +// sameSite: "lax", +// partitioned: false +// } + +// Works with JSON.stringify +const jsonString = JSON.stringify(cookie); +``` + +### Static methods + +#### `Cookie.parse(cookieString: string): Cookie` + +Parses a cookie string into a `Cookie` instance. + +```ts +const cookie = Bun.Cookie.parse("name=value; Path=/; Secure; SameSite=Lax"); + +console.log(cookie.name); // "name" +console.log(cookie.value); // "value" +console.log(cookie.path); // "/" +console.log(cookie.secure); // true +console.log(cookie.sameSite); // "lax" +``` + +#### `Cookie.from(name: string, value: string, options?: CookieInit): Cookie` + +Factory method to create a cookie. + +```ts +const cookie = Bun.Cookie.from("session", "abc123", { + httpOnly: true, + secure: true, + maxAge: 3600, +}); +``` + +## Types + +```ts +interface CookieInit { + name?: string; + value?: string; + domain?: string; + path?: string; + expires?: number | Date | string; + secure?: boolean; + sameSite?: CookieSameSite; + httpOnly?: boolean; + partitioned?: boolean; + maxAge?: number; +} + +interface CookieStoreDeleteOptions { + name: string; + domain?: string | null; + path?: string; +} + +interface CookieStoreGetOptions { + name?: string; + url?: string; +} + +type CookieSameSite = "strict" | "lax" | "none"; + +class Cookie { + constructor(name: string, value: string, options?: CookieInit); + constructor(cookieString: string); + constructor(cookieObject?: CookieInit); + + readonly name: string; + value: string; + domain?: string; + path: string; + expires?: Date; + secure: boolean; + sameSite: CookieSameSite; + partitioned: boolean; + maxAge?: number; + httpOnly: boolean; + + isExpired(): boolean; + + serialize(): string; + toString(): string; + toJSON(): CookieInit; + + static parse(cookieString: string): Cookie; + static from(name: string, value: string, options?: CookieInit): Cookie; +} + +class CookieMap implements Iterable<[string, string]> { + constructor(init?: string[][] | Record | string); + + get(name: string): string | null; + + toSetCookieHeaders(): string[]; + + has(name: string): boolean; + set(name: string, value: string, options?: CookieInit): void; + set(options: CookieInit): void; + delete(name: string): void; + delete(options: CookieStoreDeleteOptions): void; + delete(name: string, options: Omit): void; + toJSON(): Record; + + readonly size: number; + + entries(): IterableIterator<[string, string]>; + keys(): IterableIterator; + values(): IterableIterator; + forEach(callback: (value: string, key: string, map: CookieMap) => void): void; + [Symbol.iterator](): IterableIterator<[string, string]>; +} +``` diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 1c890efa3f..3c2c9f1ee5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3450,6 +3450,7 @@ declare module "bun" { interface BunRequest extends Request { params: RouterTypes.ExtractRouteParams; + readonly cookies: CookieMap; } interface GenericServeOptions { @@ -7256,4 +7257,181 @@ declare module "bun" { | [pkg: string, info: BunLockFilePackageInfo, bunTag: string] /** root */ | [pkg: string, info: Pick]; + + interface CookieInit { + name?: string; + value?: string; + domain?: string; + path?: string; + expires?: number | Date | string; + secure?: boolean; + sameSite?: CookieSameSite; + httpOnly?: boolean; + partitioned?: boolean; + maxAge?: number; + } + + interface CookieStoreDeleteOptions { + name: string; + domain?: string | null; + path?: string; + } + + interface CookieStoreGetOptions { + name?: string; + url?: string; + } + + type CookieSameSite = "strict" | "lax" | "none"; + + class Cookie { + constructor(name: string, value: string, options?: CookieInit); + constructor(cookieString: string); + constructor(cookieObject?: CookieInit); + + readonly name: string; + value: string; + domain?: string; + path: string; + expires?: Date; + secure: boolean; + sameSite: CookieSameSite; + partitioned: boolean; + maxAge?: number; + httpOnly: boolean; + + isExpired(): boolean; + + serialize(): string; + toString(): string; + toJSON(): CookieInit; + + static parse(cookieString: string): Cookie; + static from(name: string, value: string, options?: CookieInit): Cookie; + } + + /** + * A Map-like interface for working with collections of cookies. + * Implements the `Iterable` interface, allowing use with `for...of` loops. + */ + class CookieMap implements Iterable<[string, string]> { + /** + * Creates a new CookieMap instance. + * + * @param init - Optional initial data for the cookie map: + * - string: A cookie header string (e.g., "name=value; foo=bar") + * - string[][]: An array of name/value pairs (e.g., [["name", "value"], ["foo", "bar"]]) + * - Record: An object with cookie names as keys (e.g., { name: "value", foo: "bar" }) + */ + constructor(init?: string[][] | Record | string); + + /** + * Gets the value of a cookie with the specified name. + * + * @param name - The name of the cookie to retrieve + * @returns The cookie value as a string, or null if the cookie doesn't exist + */ + get(name: string): string | null; + + /** + * Gets an array of values for Set-Cookie headers in order to apply all changes to cookies. + * + * @returns An array of values for Set-Cookie headers + */ + toSetCookieHeaders(): string[]; + + /** + * Checks if a cookie with the given name exists. + * + * @param name - The name of the cookie to check + * @returns true if the cookie exists, false otherwise + */ + has(name: string): boolean; + + /** + * Adds or updates a cookie in the map. + * + * @param name - The name of the cookie + * @param value - The value of the cookie + * @param options - Optional cookie attributes + */ + set(name: string, value: string, options?: CookieInit): void; + + /** + * Adds or updates a cookie in the map using a cookie options object. + * + * @param options - Cookie options including name and value + */ + set(options: CookieInit): void; + + /** + * Removes a cookie from the map. + * + * @param name - The name of the cookie to delete + */ + delete(name: string): void; + + /** + * Removes a cookie from the map. + * + * @param options - The options for the cookie to delete + */ + delete(options: CookieStoreDeleteOptions): void; + + /** + * Removes a cookie from the map. + * + * @param name - The name of the cookie to delete + * @param options - The options for the cookie to delete + */ + delete(name: string, options: Omit): void; + + /** + * Converts the cookie map to a serializable format. + * + * @returns An array of name/value pairs + */ + toJSON(): Record; + + /** + * The number of cookies in the map. + */ + readonly size: number; + + /** + * Returns an iterator of [name, value] pairs for every cookie in the map. + * + * @returns An iterator for the entries in the map + */ + entries(): IterableIterator<[string, string]>; + + /** + * Returns an iterator of all cookie names in the map. + * + * @returns An iterator for the cookie names + */ + keys(): IterableIterator; + + /** + * Returns an iterator of all cookie values in the map. + * + * @returns An iterator for the cookie values + */ + values(): IterableIterator; + + /** + * Executes a provided function once for each cookie in the map. + * + * @param callback - Function to execute for each entry + */ + forEach(callback: (value: string, key: string, map: CookieMap) => void): void; + + /** + * Returns the default iterator for the CookieMap. + * Used by for...of loops to iterate over all entries. + * + * @returns An iterator for the entries in the map + */ + [Symbol.iterator](): IterableIterator<[string, string]>; + } } diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 574cd71c92..13c70ebb13 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -2027,6 +2027,28 @@ pub const AnyRequestContext = struct { return false; } + pub fn setCookies(self: AnyRequestContext, cookie_map: ?*JSC.WebCore.CookieMap) void { + if (self.tagged_pointer.isNull()) { + return; + } + + switch (self.tagged_pointer.tag()) { + @field(Pointer.Tag, bun.meta.typeBaseName(@typeName(HTTPServer.RequestContext))) => { + return self.tagged_pointer.as(HTTPServer.RequestContext).setCookies(cookie_map); + }, + @field(Pointer.Tag, bun.meta.typeBaseName(@typeName(HTTPSServer.RequestContext))) => { + return self.tagged_pointer.as(HTTPSServer.RequestContext).setCookies(cookie_map); + }, + @field(Pointer.Tag, bun.meta.typeBaseName(@typeName(DebugHTTPServer.RequestContext))) => { + return self.tagged_pointer.as(DebugHTTPServer.RequestContext).setCookies(cookie_map); + }, + @field(Pointer.Tag, bun.meta.typeBaseName(@typeName(DebugHTTPSServer.RequestContext))) => { + return self.tagged_pointer.as(DebugHTTPSServer.RequestContext).setCookies(cookie_map); + }, + else => @panic("Unexpected AnyRequestContext tag"), + } + } + pub fn enableTimeoutEvents(self: AnyRequestContext) void { if (self.tagged_pointer.isNull()) { return; @@ -2182,6 +2204,7 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp request_weakref: Request.WeakRef = .{}, signal: ?*JSC.WebCore.AbortSignal = null, method: HTTP.Method, + cookies: ?*JSC.WebCore.CookieMap = null, flags: NewFlags(debug_mode) = .{}, @@ -2242,6 +2265,12 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp } } + pub fn setCookies(this: *RequestContext, cookie_map: ?*JSC.WebCore.CookieMap) void { + if (this.cookies) |cookies| cookies.deref(); + this.cookies = cookie_map; + if (this.cookies) |cookies| cookies.ref(); + } + pub fn setTimeoutHandler(this: *RequestContext) void { if (this.flags.has_timeout_handler) return; if (this.resp) |resp| { @@ -2796,6 +2825,11 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp this.request_body_readable_stream_ref.deinit(); + if (this.cookies) |cookies| { + this.cookies = null; + cookies.deref(); + } + if (this.request_weakref.get()) |request| { request.request_context = AnyRequestContext.Null; // we can already clean this strong refs @@ -4313,6 +4347,12 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp } fn doWriteHeaders(this: *RequestContext, headers: *JSC.FetchHeaders) void { + if (this.cookies) |cookies| { + this.cookies = null; + defer cookies.deref(); + cookies.write(this.server.?.globalThis, ssl_enabled, @ptrCast(this.resp.?)); + } + writeHeaders(headers, ssl_enabled, this.resp); } diff --git a/src/bun.js/bindings/BunCommonStrings.h b/src/bun.js/bindings/BunCommonStrings.h index 734caa02d3..4d44948130 100644 --- a/src/bun.js/bindings/BunCommonStrings.h +++ b/src/bun.js/bindings/BunCommonStrings.h @@ -34,7 +34,10 @@ macro(OperationWasAborted, "The operation was aborted.") \ macro(OperationTimedOut, "The operation timed out.") \ macro(ConnectionWasClosed, "The connection was closed.") \ - macro(OperationFailed, "The operation failed.") + macro(OperationFailed, "The operation failed.") \ + macro(strict, "strict") \ + macro(lax, "lax") \ + macro(none, "none") // clang-format on diff --git a/src/bun.js/bindings/BunInjectedScriptHost.cpp b/src/bun.js/bindings/BunInjectedScriptHost.cpp index b044e9bce1..51e8ef1ef5 100644 --- a/src/bun.js/bindings/BunInjectedScriptHost.cpp +++ b/src/bun.js/bindings/BunInjectedScriptHost.cpp @@ -14,6 +14,9 @@ #include "JSURLSearchParams.h" #include "JSDOMFormData.h" #include +#include "JSCookie.h" +#include "JSCookieMap.h" + namespace Bun { using namespace JSC; @@ -150,6 +153,7 @@ JSValue BunInjectedScriptHost::getInternalProperties(VM& vm, JSGlobalObject* exe RETURN_IF_EXCEPTION(scope, {}); return array; } + } else if (type == JSAsJSONType) { if (auto* params = jsDynamicCast(value)) { auto* array = constructEmptyArray(exec, nullptr); @@ -157,6 +161,20 @@ JSValue BunInjectedScriptHost::getInternalProperties(VM& vm, JSGlobalObject* exe RETURN_IF_EXCEPTION(scope, {}); return array; } + + if (auto* cookie = jsDynamicCast(value)) { + auto* array = constructEmptyArray(exec, nullptr); + constructDataProperties(vm, exec, array, WebCore::getInternalProperties(vm, exec, cookie)); + RETURN_IF_EXCEPTION(scope, {}); + return array; + } + + if (auto* cookieMap = jsDynamicCast(value)) { + auto* array = constructEmptyArray(exec, nullptr); + constructDataProperties(vm, exec, array, WebCore::getInternalProperties(vm, exec, cookieMap)); + RETURN_IF_EXCEPTION(scope, {}); + return array; + } } } diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index e619e7b6da..964d9cad4b 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -39,6 +39,8 @@ #include "GeneratedBunObject.h" #include "JavaScriptCore/BunV8HeapSnapshotBuilder.h" #include "BunObjectModule.h" +#include "JSCookie.h" +#include "JSCookieMap.h" #ifdef WIN32 #include @@ -82,6 +84,9 @@ static JSValue BunObject_getter_wrap_ArrayBufferSink(VM& vm, JSObject* bunObject return jsCast(bunObject->globalObject())->ArrayBufferSink(); } +static JSValue constructCookieObject(VM& vm, JSObject* bunObject); +static JSValue constructCookieMapObject(VM& vm, JSObject* bunObject); + static JSValue constructEnvObject(VM& vm, JSObject* object) { return jsCast(object->globalObject())->processEnvObject(); @@ -694,6 +699,8 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj @begin bunObjectTable $ constructBunShell DontDelete|PropertyCallback ArrayBufferSink BunObject_getter_wrap_ArrayBufferSink DontDelete|PropertyCallback + Cookie constructCookieObject DontDelete|ReadOnly|PropertyCallback + CookieMap constructCookieMapObject DontDelete|ReadOnly|PropertyCallback CryptoHasher BunObject_getter_wrap_CryptoHasher DontDelete|PropertyCallback FFI BunObject_getter_wrap_FFI DontDelete|PropertyCallback FileSystemRouter BunObject_getter_wrap_FileSystemRouter DontDelete|PropertyCallback @@ -845,6 +852,18 @@ public: const JSC::ClassInfo JSBunObject::s_info = { "Bun"_s, &Base::s_info, &bunObjectTable, nullptr, CREATE_METHOD_TABLE(JSBunObject) }; +static JSValue constructCookieObject(VM& vm, JSObject* bunObject) +{ + auto* zigGlobalObject = jsCast(bunObject->globalObject()); + return WebCore::JSCookie::getConstructor(vm, zigGlobalObject); +} + +static JSValue constructCookieMapObject(VM& vm, JSObject* bunObject) +{ + auto* zigGlobalObject = jsCast(bunObject->globalObject()); + return WebCore::JSCookieMap::getConstructor(vm, zigGlobalObject); +} + JSC::JSObject* createBunObject(VM& vm, JSObject* globalObject) { return JSBunObject::create(vm, jsCast(globalObject)); diff --git a/src/bun.js/bindings/BunString.h b/src/bun.js/bindings/BunString.h index ea5908eae4..883bb9c40b 100644 --- a/src/bun.js/bindings/BunString.h +++ b/src/bun.js/bindings/BunString.h @@ -38,6 +38,14 @@ public: WTF::StringView m_view {}; bool m_isCString { false }; + std::span bytes() const + { + if (m_isCString) { + return std::span(reinterpret_cast(m_underlying.data()), m_underlying.length()); + } + return std::span(reinterpret_cast(m_view.span8().data()), m_view.length()); + } + std::span span() const { if (m_isCString) { diff --git a/src/bun.js/bindings/Cookie.cpp b/src/bun.js/bindings/Cookie.cpp new file mode 100644 index 0000000000..9f7ae23042 --- /dev/null +++ b/src/bun.js/bindings/Cookie.cpp @@ -0,0 +1,313 @@ +#include "Cookie.h" +#include "JSCookie.h" +#include "helpers.h" +#include +#include +#include +#include +#include "HTTPParsers.h" +namespace WebCore { + +extern "C" JSC::EncodedJSValue Cookie__create(JSDOMGlobalObject* globalObject, const ZigString* name, const ZigString* value, const ZigString* domain, const ZigString* path, double expires, bool secure, int32_t sameSite, bool httpOnly, double maxAge, bool partitioned) +{ + String nameStr = Zig::toString(*name); + String valueStr = Zig::toString(*value); + String domainStr = Zig::toString(*domain); + String pathStr = Zig::toString(*path); + + CookieSameSite sameSiteEnum; + switch (sameSite) { + case 0: + sameSiteEnum = CookieSameSite::Strict; + break; + case 1: + sameSiteEnum = CookieSameSite::Lax; + break; + case 2: + sameSiteEnum = CookieSameSite::None; + break; + default: + sameSiteEnum = CookieSameSite::Strict; + } + + auto result = Cookie::create(nameStr, valueStr, domainStr, pathStr, expires, secure, sameSiteEnum, httpOnly, maxAge, partitioned); + return JSC::JSValue::encode(WebCore::toJSNewlyCreated(globalObject, globalObject, WTFMove(result))); +} + +extern "C" WebCore::Cookie* Cookie__fromJS(JSC::EncodedJSValue value) +{ + return WebCoreCast(value); +} + +Cookie::~Cookie() = default; + +Cookie::Cookie(const String& name, const String& value, + const String& domain, const String& path, + int64_t expires, bool secure, CookieSameSite sameSite, + bool httpOnly, double maxAge, bool partitioned) + : m_name(name) + , m_value(value) + , m_domain(domain) + , m_path(path.isEmpty() ? "/"_s : path) + , m_expires(expires) + , m_secure(secure) + , m_sameSite(sameSite) + , m_httpOnly(httpOnly) + , m_maxAge(maxAge) + , m_partitioned(partitioned) +{ +} + +Ref Cookie::create(const String& name, const String& value, + const String& domain, const String& path, + int64_t expires, bool secure, CookieSameSite sameSite, + bool httpOnly, double maxAge, bool partitioned) +{ + return adoptRef(*new Cookie(name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned)); +} + +Ref Cookie::from(const String& name, const String& value, + const String& domain, const String& path, + int64_t expires, bool secure, CookieSameSite sameSite, + bool httpOnly, double maxAge, bool partitioned) +{ + return create(name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned); +} + +String Cookie::serialize(JSC::VM& vm, const std::span> cookies) +{ + if (cookies.empty()) + return emptyString(); + + StringBuilder builder; + bool first = true; + + for (const auto& cookie : cookies) { + if (!first) + builder.append("; "_s); + + cookie->appendTo(vm, builder); + first = false; + } + + return builder.toString(); +} + +ExceptionOr> Cookie::parse(StringView cookieString) +{ + // RFC 6265 sec 4.1.1, RFC 2616 2.2 defines a cookie name consists of one char minimum, plus '='. + if (UNLIKELY(cookieString.length() < 2)) { + return Exception { TypeError, "Invalid cookie string: empty"_s }; + } + + // Find the first name-value pair + size_t firstSemicolonPos = cookieString.find(';'); + StringView cookiePair = firstSemicolonPos == notFound ? cookieString : cookieString.substring(0, firstSemicolonPos); + + size_t firstEqualsPos = cookiePair.find('='); + if (UNLIKELY(firstEqualsPos == notFound)) { + return Exception { TypeError, "Invalid cookie string: no '=' found"_s }; + } + + String name = cookiePair.substring(0, firstEqualsPos).trim(isASCIIWhitespace).toString(); + if (name.isEmpty()) + return Exception { TypeError, "Invalid cookie string: name cannot be empty"_s }; + + ASSERT(isValidHTTPHeaderValue(name)); + String value = cookiePair.substring(firstEqualsPos + 1).trim(isASCIIWhitespace).toString(); + + // Default values + String domain; + String path = "/"_s; + int64_t expires = Cookie::emptyExpiresAtValue; + bool secure = false; + CookieSameSite sameSite = CookieSameSite::Lax; + bool httpOnly = false; + double maxAge = std::numeric_limits::quiet_NaN(); + bool partitioned = false; + bool hasMaxAge = false; + ASSERT(value.isEmpty() || isValidHTTPHeaderValue(value)); + // Parse attributes if there are any + if (firstSemicolonPos != notFound) { + auto attributesString = cookieString.substring(firstSemicolonPos + 1); + + for (auto attribute : attributesString.split(';')) { + auto trimmedAttribute = attribute.trim(isASCIIWhitespace); + size_t assignmentPos = trimmedAttribute.find('='); + + String attributeName; + String attributeValue; + + if (assignmentPos != notFound) { + attributeName = trimmedAttribute.substring(0, assignmentPos).trim(isASCIIWhitespace).convertToASCIILowercase(); + attributeValue = trimmedAttribute.substring(assignmentPos + 1).trim(isASCIIWhitespace).toString(); + } else { + attributeName = trimmedAttribute.convertToASCIILowercase(); + attributeValue = emptyString(); + } + + if (attributeName == "domain"_s) { + if (!attributeValue.isEmpty()) { + domain = attributeValue.convertToASCIILowercase(); + } + } else if (attributeName == "path"_s) { + if (!attributeValue.isEmpty() && attributeValue.startsWith('/')) + path = attributeValue; + } else if (attributeName == "expires"_s && !hasMaxAge && !attributeValue.isEmpty()) { + if (UNLIKELY(!attributeValue.is8Bit())) { + auto asLatin1 = attributeValue.latin1(); + if (auto parsed = WTF::parseDate({ reinterpret_cast(asLatin1.data()), asLatin1.length() })) { + expires = static_cast(parsed); + } + } else { + auto nullTerminated = attributeValue.utf8(); + if (auto parsed = WTF::parseDate(std::span(reinterpret_cast(nullTerminated.data()), nullTerminated.length()))) { + expires = static_cast(parsed); + } + } + } else if (attributeName == "max-age"_s) { + if (auto parsed = WTF::parseIntegerAllowingTrailingJunk(attributeValue); parsed.has_value()) { + maxAge = static_cast(parsed.value()); + hasMaxAge = true; + } + } else if (attributeName == "secure"_s) { + secure = true; + } else if (attributeName == "httponly"_s) { + httpOnly = true; + } else if (attributeName == "partitioned"_s) { + partitioned = true; + } else if (attributeName == "samesite"_s) { + if (WTF::equalIgnoringASCIICase(attributeValue, "strict"_s)) + sameSite = CookieSameSite::Strict; + else if (WTF::equalIgnoringASCIICase(attributeValue, "lax"_s)) + sameSite = CookieSameSite::Lax; + else if (WTF::equalIgnoringASCIICase(attributeValue, "none"_s)) + sameSite = CookieSameSite::None; + } + } + } + + return Cookie::create(name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned); +} + +bool Cookie::isExpired() const +{ + if (m_expires == Cookie::emptyExpiresAtValue || m_expires < 1) + return false; // Session cookie + + auto currentTime = WTF::WallTime::now().secondsSinceEpoch().seconds() * 1000.0; + return currentTime > m_expires; +} + +String Cookie::toString(JSC::VM& vm) const +{ + StringBuilder builder; + appendTo(vm, builder); + return builder.toString(); +} + +void Cookie::appendTo(JSC::VM& vm, StringBuilder& builder) const +{ + // Name=Value is the basic format + builder.append(WTF::encodeWithURLEscapeSequences(m_name)); + builder.append('='); + builder.append(WTF::encodeWithURLEscapeSequences(m_value)); + + // Add domain if present + if (!m_domain.isEmpty()) { + builder.append("; Domain="_s); + builder.append(m_domain); + } + + if (!m_path.isEmpty() && m_path != "/"_s) { + builder.append("; Path="_s); + builder.append(m_path); + } + + // Add expires if present + if (hasExpiry()) { + builder.append("; Expires="_s); + // In a real implementation, this would convert the timestamp to a proper date string + // For now, just use a numeric timestamp + WTF::GregorianDateTime dateTime; + vm.dateCache.msToGregorianDateTime(m_expires, WTF::TimeType::UTCTime, dateTime); + builder.append(WTF::makeRFC2822DateString(dateTime.weekDay(), dateTime.monthDay(), dateTime.month(), dateTime.year(), dateTime.hour(), dateTime.minute(), dateTime.second(), dateTime.utcOffsetInMinute())); + } + + // Add Max-Age if present + if (!std::isnan(m_maxAge)) { + builder.append("; Max-Age="_s); + builder.append(String::number(m_maxAge)); + } + + // Add secure flag if true + if (m_secure) + builder.append("; Secure"_s); + + // Add HttpOnly flag if true + if (m_httpOnly) + builder.append("; HttpOnly"_s); + + // Add Partitioned flag if true + if (m_partitioned) + builder.append("; Partitioned"_s); + + // Add SameSite directive + + switch (m_sameSite) { + case CookieSameSite::Strict: + builder.append("; SameSite=Strict"_s); + break; + case CookieSameSite::Lax: + // lax is the default. but we still need to set it explicitly. + // https://groups.google.com/a/chromium.org/g/blink-dev/c/AknSSyQTGYs/m/YKBxPCScCwAJ + builder.append("; SameSite=Lax"_s); + break; + case CookieSameSite::None: + builder.append("; SameSite=None"_s); + break; + } +} + +JSC::JSValue Cookie::toJSON(JSC::VM& vm, JSC::JSGlobalObject* globalObject) const +{ + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* object = JSC::constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + RETURN_IF_EXCEPTION(scope, JSC::jsNull()); + + auto& builtinNames = Bun::builtinNames(vm); + + object->putDirect(vm, vm.propertyNames->name, JSC::jsString(vm, m_name)); + object->putDirect(vm, vm.propertyNames->value, JSC::jsString(vm, m_value)); + + if (!m_domain.isEmpty()) + object->putDirect(vm, builtinNames.domainPublicName(), JSC::jsString(vm, m_domain)); + + object->putDirect(vm, builtinNames.pathPublicName(), JSC::jsString(vm, m_path)); + + if (hasExpiry()) + object->putDirect(vm, builtinNames.expiresPublicName(), JSC::DateInstance::create(vm, globalObject->dateStructure(), m_expires)); + + if (!std::isnan(m_maxAge)) + object->putDirect(vm, builtinNames.maxAgePublicName(), JSC::jsNumber(m_maxAge)); + + object->putDirect(vm, builtinNames.securePublicName(), JSC::jsBoolean(m_secure)); + object->putDirect(vm, builtinNames.sameSitePublicName(), toJS(globalObject, m_sameSite)); + object->putDirect(vm, builtinNames.httpOnlyPublicName(), JSC::jsBoolean(m_httpOnly)); + object->putDirect(vm, builtinNames.partitionedPublicName(), JSC::jsBoolean(m_partitioned)); + + return object; +} + +size_t Cookie::memoryCost() const +{ + size_t cost = sizeof(Cookie); + cost += m_name.sizeInBytes(); + cost += m_value.sizeInBytes(); + cost += m_domain.sizeInBytes(); + cost += m_path.sizeInBytes(); + return cost; +} + +} // namespace WebCore diff --git a/src/bun.js/bindings/Cookie.h b/src/bun.js/bindings/Cookie.h new file mode 100644 index 0000000000..4018b93f36 --- /dev/null +++ b/src/bun.js/bindings/Cookie.h @@ -0,0 +1,114 @@ +#pragma once +#include "root.h" + +#include "ExceptionOr.h" +#include +#include + +namespace WebCore { + +enum class CookieSameSite : uint8_t { + Strict, + Lax, + None +}; + +JSC::JSValue toJS(JSC::JSGlobalObject*, CookieSameSite); + +struct CookieInit { + String name = String(); + String value = String(); + String domain = String(); + String path = String(); + + int64_t expires = emptyExpiresAtValue; + bool secure = false; + CookieSameSite sameSite = CookieSameSite::Lax; + bool httpOnly = false; + double maxAge = std::numeric_limits::quiet_NaN(); + bool partitioned = false; + static constexpr int64_t emptyExpiresAtValue = std::numeric_limits::min(); + + static std::optional fromJS(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue value); + static std::optional fromJS(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue input, String name, String cookieValue); +}; + +class Cookie : public RefCounted { +public: + ~Cookie(); + static constexpr int64_t emptyExpiresAtValue = std::numeric_limits::min(); + static Ref create(const String& name, const String& value, + const String& domain, const String& path, + int64_t expires, bool secure, CookieSameSite sameSite, + bool httpOnly, double maxAge, bool partitioned); + + static Ref create(const CookieInit& init) + { + return create(init.name, init.value, init.domain, init.path, init.expires, init.secure, init.sameSite, init.httpOnly, init.maxAge, init.partitioned); + } + + static ExceptionOr> parse(StringView cookieString); + static Ref from(const String& name, const String& value, + const String& domain, const String& path, + int64_t expires, bool secure, CookieSameSite sameSite, + bool httpOnly, double maxAge, bool partitioned); + + static String serialize(JSC::VM& vm, const std::span> cookies); + + const String& name() const { return m_name; } + void setName(const String& name) { m_name = name; } + + const String& value() const { return m_value; } + void setValue(const String& value) { m_value = value; } + + const String& domain() const { return m_domain; } + void setDomain(const String& domain) { m_domain = domain; } + + const String& path() const { return m_path; } + void setPath(const String& path) { m_path = path; } + + int64_t expires() const { return m_expires; } + void setExpires(int64_t ms) { m_expires = ms; } + bool hasExpiry() const { return m_expires != emptyExpiresAtValue; } + + bool secure() const { return m_secure; } + void setSecure(bool secure) { m_secure = secure; } + + CookieSameSite sameSite() const { return m_sameSite; } + void setSameSite(CookieSameSite sameSite) { m_sameSite = sameSite; } + + bool httpOnly() const { return m_httpOnly; } + void setHttpOnly(bool httpOnly) { m_httpOnly = httpOnly; } + + double maxAge() const { return m_maxAge; } + void setMaxAge(double maxAge) { m_maxAge = maxAge; } + + bool partitioned() const { return m_partitioned; } + void setPartitioned(bool partitioned) { m_partitioned = partitioned; } + + bool isExpired() const; + + void appendTo(JSC::VM& vm, StringBuilder& builder) const; + String toString(JSC::VM& vm) const; + JSC::JSValue toJSON(JSC::VM& vm, JSC::JSGlobalObject*) const; + size_t memoryCost() const; + +private: + Cookie(const String& name, const String& value, + const String& domain, const String& path, + int64_t expires, bool secure, CookieSameSite sameSite, + bool httpOnly, double maxAge, bool partitioned); + + String m_name; + String m_value; + String m_domain; + String m_path; + int64_t m_expires = Cookie::emptyExpiresAtValue; + bool m_secure = false; + CookieSameSite m_sameSite = CookieSameSite::Lax; + bool m_httpOnly = false; + double m_maxAge = 0; + bool m_partitioned = false; +}; + +} // namespace WebCore diff --git a/src/bun.js/bindings/CookieMap.cpp b/src/bun.js/bindings/CookieMap.cpp new file mode 100644 index 0000000000..acc0ff4e5d --- /dev/null +++ b/src/bun.js/bindings/CookieMap.cpp @@ -0,0 +1,288 @@ +#include "CookieMap.h" +#include "JSCookieMap.h" +#include +#include "helpers.h" +#include +#include +#include "HTTPParsers.h" +#include "decodeURIComponentSIMD.h" +#include "BunString.h" +namespace WebCore { + +template +void CookieMap__writeFetchHeadersToUWSResponse(CookieMap* cookie_map, JSC::JSGlobalObject* global_this, uWS::HttpResponse* res) +{ + // Loop over modified cookies and write Set-Cookie headers to the response + for (auto& cookie : cookie_map->getAllChanges()) { + auto utf8 = cookie->toString(global_this->vm()).utf8(); + res->writeHeader("Set-Cookie", utf8.data()); + } +} +extern "C" void CookieMap__write(CookieMap* cookie_map, JSC::JSGlobalObject* global_this, bool ssl_enabled, void* arg2) +{ + if (ssl_enabled) { + CookieMap__writeFetchHeadersToUWSResponse(cookie_map, global_this, reinterpret_cast*>(arg2)); + } else { + CookieMap__writeFetchHeadersToUWSResponse(cookie_map, global_this, reinterpret_cast*>(arg2)); + } +} + +extern "C" void CookieMap__ref(CookieMap* cookie_map) +{ + cookie_map->ref(); +} + +extern "C" void CookieMap__deref(CookieMap* cookie_map) +{ + cookie_map->deref(); +} + +CookieMap::~CookieMap() = default; + +CookieMap::CookieMap() +{ +} + +CookieMap::CookieMap(Vector>&& cookies) + : m_modifiedCookies(WTFMove(cookies)) +{ +} + +CookieMap::CookieMap(Vector>&& cookies) + : m_originalCookies(WTFMove(cookies)) +{ +} + +ExceptionOr> CookieMap::create(std::variant>, HashMap, String>&& variant, bool throwOnInvalidCookieString) +{ + auto visitor = WTF::makeVisitor( + [&](const Vector>& pairs) -> ExceptionOr> { + Vector> cookies; + for (const auto& pair : pairs) { + if (pair.size() == 2) { + if (!pair[1].isEmpty() && !isValidHTTPHeaderValue(pair[1])) { + if (throwOnInvalidCookieString) { + return Exception { TypeError, "Invalid cookie string: cookie value is not valid"_s }; + } else { + continue; + } + } + + cookies.append(KeyValuePair(pair[0], pair[1])); + } else if (throwOnInvalidCookieString) { + return Exception { TypeError, "Invalid cookie string: expected name=value pair"_s }; + } + } + return adoptRef(*new CookieMap(WTFMove(cookies))); + }, + [&](const HashMap& pairs) -> ExceptionOr> { + Vector> cookies; + for (const auto& entry : pairs) { + if (!entry.value.isEmpty() && !isValidHTTPHeaderValue(entry.value)) { + if (throwOnInvalidCookieString) { + return Exception { TypeError, "Invalid cookie string: cookie value is not valid"_s }; + } else { + continue; + } + } + cookies.append(KeyValuePair(entry.key, entry.value)); + } + + return adoptRef(*new CookieMap(WTFMove(cookies))); + }, + [&](const String& cookieString) -> ExceptionOr> { + StringView forCookieHeader = cookieString; + if (forCookieHeader.isEmpty()) { + return adoptRef(*new CookieMap()); + } + + auto pairs = forCookieHeader.split(';'); + Vector> cookies; + + bool hasAnyPercentEncoded = forCookieHeader.find('%') != notFound; + for (auto pair : pairs) { + String name = ""_s; + String value = ""_s; + + auto equalsPos = pair.find('='); + if (equalsPos == notFound) { + continue; + } + + auto nameView = pair.substring(0, equalsPos).trim(isASCIIWhitespace); + auto valueView = pair.substring(equalsPos + 1).trim(isASCIIWhitespace); + + if (nameView.isEmpty()) { + continue; + } + + if (hasAnyPercentEncoded) { + Bun::UTF8View utf8View(nameView); + name = Bun::decodeURIComponentSIMD(utf8View.bytes()); + } else { + name = nameView.toString(); + } + + if (hasAnyPercentEncoded) { + Bun::UTF8View utf8View(valueView); + value = Bun::decodeURIComponentSIMD(utf8View.bytes()); + } else { + value = valueView.toString(); + } + + cookies.append(KeyValuePair(name, value)); + } + + return adoptRef(*new CookieMap(WTFMove(cookies))); + }); + + return std::visit(visitor, variant); +} + +std::optional CookieMap::get(const String& name) const +{ + auto modifiedCookieIndex = m_modifiedCookies.findIf([&](auto& cookie) { + return cookie->name() == name; + }); + if (modifiedCookieIndex != notFound) { + // a set cookie with an empty value is treated as not existing, because that is what delete() sets + if (m_modifiedCookies[modifiedCookieIndex]->value().isEmpty()) { + return std::nullopt; + } + return std::optional(m_modifiedCookies[modifiedCookieIndex]->value()); + } + auto originalCookieIndex = m_originalCookies.findIf([&](auto& cookie) { + return cookie.key == name; + }); + if (originalCookieIndex != notFound) { + return std::optional(m_originalCookies[originalCookieIndex].value); + } + return std::nullopt; +} + +Vector> CookieMap::getAll() const +{ + Vector> all; + for (const auto& cookie : m_modifiedCookies) { + if (cookie->value().isEmpty()) continue; + all.append(KeyValuePair(cookie->name(), cookie->value())); + } + for (const auto& cookie : m_originalCookies) { + all.append(KeyValuePair(cookie.key, cookie.value)); + } + return all; +} + +bool CookieMap::has(const String& name) const +{ + return get(name).has_value(); +} + +void CookieMap::removeInternal(const String& name) +{ + // Remove any existing matching cookies + m_originalCookies.removeAllMatching([&](auto& cookie) { + return cookie.key == name; + }); + m_modifiedCookies.removeAllMatching([&](auto& cookie) { + return cookie->name() == name; + }); +} + +void CookieMap::set(Ref cookie) +{ + removeInternal(cookie->name()); + // Add the new cookie + m_modifiedCookies.append(WTFMove(cookie)); +} + +void CookieMap::remove(const CookieStoreDeleteOptions& options) +{ + removeInternal(options.name); + + String name = options.name; + String domain = options.domain; + String path = options.path; + + // Add the new cookie + auto cookie = Cookie::create(name, ""_s, domain, path, 1, false, CookieSameSite::Lax, false, std::numeric_limits::quiet_NaN(), false); + m_modifiedCookies.append(WTFMove(cookie)); +} + +size_t CookieMap::size() const +{ + size_t size = 0; + for (const auto& cookie : m_modifiedCookies) { + if (cookie->value().isEmpty()) continue; + size += 1; + } + size += m_originalCookies.size(); + return size; +} + +JSC::JSValue CookieMap::toJSON(JSC::JSGlobalObject* globalObject) const +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + // Create an object to hold cookie key-value pairs + auto* object = JSC::constructEmptyObject(globalObject); + RETURN_IF_EXCEPTION(scope, JSC::jsNull()); + + // Add modified cookies to the object + for (const auto& cookie : m_modifiedCookies) { + if (!cookie->value().isEmpty()) { + object->putDirect(vm, JSC::Identifier::fromString(vm, cookie->name()), JSC::jsString(vm, cookie->value())); + RETURN_IF_EXCEPTION(scope, JSC::jsNull()); + } + } + + // Add original cookies to the object + for (const auto& cookie : m_originalCookies) { + // Skip if this cookie name was already added from modified cookies + if (!object->hasProperty(globalObject, JSC::Identifier::fromString(vm, cookie.key))) { + object->putDirect(vm, JSC::Identifier::fromString(vm, cookie.key), JSC::jsString(vm, cookie.value)); + RETURN_IF_EXCEPTION(scope, JSC::jsNull()); + } + } + + return object; +} + +size_t CookieMap::memoryCost() const +{ + size_t cost = sizeof(CookieMap); + for (auto& cookie : m_originalCookies) { + cost += cookie.key.sizeInBytes(); + cost += cookie.value.sizeInBytes(); + } + for (auto& cookie : m_modifiedCookies) { + cost += cookie->name().sizeInBytes(); + cost += cookie->value().sizeInBytes(); + } + return cost; +} + +std::optional> CookieMap::Iterator::next() +{ + while (m_index < m_target->m_modifiedCookies.size() + m_target->m_originalCookies.size()) { + if (m_index >= m_target->m_modifiedCookies.size()) { + return m_target->m_originalCookies[m_index++]; + } + + auto result = m_target->m_modifiedCookies[m_index++]; + if (result->value().isEmpty()) { + continue; // deleted; skip + } + + return KeyValuePair(result->name(), result->value()); + } + return std::nullopt; +} + +CookieMap::Iterator::Iterator(CookieMap& cookieMap) + : m_target(cookieMap) +{ +} + +} // namespace WebCore diff --git a/src/bun.js/bindings/CookieMap.h b/src/bun.js/bindings/CookieMap.h new file mode 100644 index 0000000000..07ec4200b2 --- /dev/null +++ b/src/bun.js/bindings/CookieMap.h @@ -0,0 +1,72 @@ +#pragma once +#include "root.h" + +#include "Cookie.h" +#include "ExceptionOr.h" +#include +#include +#include +#include +#include + +namespace WebCore { + +struct CookieStoreGetOptions { + String name {}; + String url {}; +}; + +struct CookieStoreDeleteOptions { + String name {}; + String domain {}; + String path {}; +}; + +class CookieMap : public RefCounted { +public: + ~CookieMap(); + + // Define a simple struct to hold the key-value pair + + static ExceptionOr> create(std::variant>, HashMap, String>&& init, bool throwOnInvalidCookieString = true); + + std::optional get(const String& name) const; + Vector> getAll() const; + Vector> getAllChanges() const { return m_modifiedCookies; } + + bool has(const String& name) const; + + void set(Ref); + + void remove(const CookieStoreDeleteOptions& options); + + JSC::JSValue toJSON(JSC::JSGlobalObject*) const; + size_t size() const; + size_t memoryCost() const; + + class Iterator { + public: + explicit Iterator(CookieMap&); + + std::optional> next(); + + private: + Ref m_target; + size_t m_index { 0 }; + }; + + Iterator createIterator() { return Iterator { *this }; } + Iterator createIterator(const void*) { return Iterator { *this }; } + +private: + CookieMap(); + CookieMap(Vector>&& cookies); + CookieMap(Vector>&& cookies); + + void removeInternal(const String& name); + + Vector> m_originalCookies; + Vector> m_modifiedCookies; +}; + +} // namespace WebCore diff --git a/src/bun.js/bindings/JSBunRequest.cpp b/src/bun.js/bindings/JSBunRequest.cpp index 82f87ca1dc..ce05edb03a 100644 --- a/src/bun.js/bindings/JSBunRequest.cpp +++ b/src/bun.js/bindings/JSBunRequest.cpp @@ -7,13 +7,20 @@ #include "ZigGlobalObject.h" #include "AsyncContextFrame.h" #include +#include "JSFetchHeaders.h" +#include "JSCookieMap.h" +#include "Cookie.h" +#include "CookieMap.h" +#include "JSDOMExceptionHandling.h" namespace Bun { static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetParams); +static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetCookies); static const HashTableValue JSBunRequestPrototypeValues[] = { { "params"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetParams, nullptr } }, + { "cookies"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetCookies, nullptr } }, }; JSBunRequest* JSBunRequest::create(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr, JSObject* params) @@ -51,6 +58,22 @@ void JSBunRequest::setParams(JSObject* params) m_params.set(Base::vm(), this, params); } +JSObject* JSBunRequest::cookies() const +{ + if (m_cookies) { + return m_cookies.get(); + } + return nullptr; +} + +extern "C" void Request__setCookiesOnRequestContext(void* internalZigRequestPointer, CookieMap* cookieMap); + +void JSBunRequest::setCookies(JSObject* cookies) +{ + m_cookies.set(Base::vm(), this, cookies); + Request__setCookiesOnRequestContext(this->wrapped(), WebCoreCast(JSValue::encode(cookies))); +} + JSBunRequest::JSBunRequest(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr) : Base(vm, structure, sinkPtr) { @@ -61,6 +84,7 @@ void JSBunRequest::finishCreation(JSC::VM& vm, JSObject* params) { Base::finishCreation(vm); m_params.setMayBeNull(vm, this, params); + m_cookies.clear(); Bun__JSRequest__calculateEstimatedByteSize(this->wrapped()); auto size = Request__estimatedSize(this->wrapped()); @@ -73,6 +97,7 @@ void JSBunRequest::visitChildrenImpl(JSCell* cell, Visitor& visitor) JSBunRequest* thisCallSite = jsCast(cell); Base::visitChildren(thisCallSite, visitor); visitor.append(thisCallSite->m_params); + visitor.append(thisCallSite->m_cookies); } DEFINE_VISIT_CHILDREN(JSBunRequest); @@ -137,6 +162,47 @@ JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetParams, (JSC::JSGlobalObject * globalO return JSValue::encode(params); } +JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetCookies, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + JSBunRequest* request = jsDynamicCast(JSValue::decode(thisValue)); + if (!request) + return JSValue::encode(jsUndefined()); + + auto* cookies = request->cookies(); + if (!cookies) { + auto& vm = globalObject->vm(); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& names = builtinNames(vm); + JSC::JSValue headersValue = request->get(globalObject, names.headersPublicName()); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + auto* headers = jsDynamicCast(headersValue); + if (!headers) + return JSValue::encode(jsUndefined()); + + auto& fetchHeaders = headers->wrapped(); + + auto cookieHeader = fetchHeaders.internalHeaders().get(HTTPHeaderName::Cookie); + + // Create a CookieMap from the cookie header + auto cookieMapResult = WebCore::CookieMap::create(cookieHeader); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + if (cookieMapResult.hasException()) { + WebCore::propagateException(*globalObject, throwScope, cookieMapResult.releaseException()); + return JSValue::encode(jsUndefined()); + } + + auto cookieMap = cookieMapResult.releaseReturnValue(); + + // Convert to JS + auto cookies = WebCore::toJSNewlyCreated(globalObject, jsCast(globalObject), WTFMove(cookieMap)); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + request->setCookies(cookies.getObject()); + return JSValue::encode(cookies); + } + + return JSValue::encode(cookies); +} + Structure* createJSBunRequestStructure(JSC::VM& vm, Zig::GlobalObject* globalObject) { auto prototypeStructure = JSBunRequestPrototype::createStructure(vm, globalObject, globalObject->JSRequestPrototype()); diff --git a/src/bun.js/bindings/JSBunRequest.h b/src/bun.js/bindings/JSBunRequest.h index 92a5d936fa..3c41b504ff 100644 --- a/src/bun.js/bindings/JSBunRequest.h +++ b/src/bun.js/bindings/JSBunRequest.h @@ -1,5 +1,6 @@ #pragma once +#include "JSCookieMap.h" #include "root.h" #include "ZigGeneratedClasses.h" @@ -32,11 +33,15 @@ public: JSObject* params() const; void setParams(JSObject* params); + JSObject* cookies() const; + void setCookies(JSObject* cookies); + private: JSBunRequest(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr); void finishCreation(JSC::VM& vm, JSObject* params); mutable JSC::WriteBarrier m_params; + mutable JSC::WriteBarrier m_cookies; }; JSC::Structure* createJSBunRequestStructure(JSC::VM&, Zig::GlobalObject*); diff --git a/src/bun.js/bindings/JSDOMExceptionHandling.h b/src/bun.js/bindings/JSDOMExceptionHandling.h index 553dad6852..20e284a71d 100644 --- a/src/bun.js/bindings/JSDOMExceptionHandling.h +++ b/src/bun.js/bindings/JSDOMExceptionHandling.h @@ -89,10 +89,9 @@ inline void propagateException(JSC::JSGlobalObject& lexicalGlobalObject, JSC::Th propagateException(lexicalGlobalObject, throwScope, value.releaseException()); } -inline void propagateException(JSC::JSGlobalObject* lexicalGlobalObject, JSC::ThrowScope& throwScope, ExceptionOr&& value) +ALWAYS_INLINE void propagateException(JSC::JSGlobalObject* lexicalGlobalObject, JSC::ThrowScope& throwScope, Exception&& exception) { - if (UNLIKELY(value.hasException())) - propagateException(lexicalGlobalObject, throwScope, value.releaseException()); + return propagateException(*lexicalGlobalObject, throwScope, WTFMove(exception)); } template void invokeFunctorPropagatingExceptionIfNecessary(JSC::JSGlobalObject& lexicalGlobalObject, JSC::ThrowScope& throwScope, Functor&& functor) diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index ec7e6979d2..627a1370d9 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -130,6 +130,7 @@ #include "ObjectBindings.h" #include +#include "wtf-bindings.h" #if OS(DARWIN) #if BUN_DEBUG @@ -6058,7 +6059,7 @@ extern "C" EncodedJSValue JSC__JSValue__dateInstanceFromNullTerminatedString(JSC // this is largely copied from dateProtoFuncToISOString extern "C" int JSC__JSValue__toISOString(JSC::JSGlobalObject* globalObject, EncodedJSValue dateValue, char* buf) { - char buffer[29]; + char buffer[64]; JSC::DateInstance* thisDateObj = JSC::jsDynamicCast(JSC::JSValue::decode(dateValue)); if (!thisDateObj) return -1; @@ -6068,28 +6069,7 @@ extern "C" int JSC__JSValue__toISOString(JSC::JSGlobalObject* globalObject, Enco auto& vm = JSC::getVM(globalObject); - const GregorianDateTime* gregorianDateTime = thisDateObj->gregorianDateTimeUTC(vm.dateCache); - if (!gregorianDateTime) - return -1; - - // If the year is outside the bounds of 0 and 9999 inclusive we want to use the extended year format (ES 15.9.1.15.1). - int ms = static_cast(fmod(thisDateObj->internalNumber(), msPerSecond)); - if (ms < 0) - ms += msPerSecond; - - int charactersWritten; - if (gregorianDateTime->year() > 9999 || gregorianDateTime->year() < 0) - charactersWritten = snprintf(buffer, sizeof(buffer), "%+07d-%02d-%02dT%02d:%02d:%02d.%03dZ", gregorianDateTime->year(), gregorianDateTime->month() + 1, gregorianDateTime->monthDay(), gregorianDateTime->hour(), gregorianDateTime->minute(), gregorianDateTime->second(), ms); - else - charactersWritten = snprintf(buffer, sizeof(buffer), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", gregorianDateTime->year(), gregorianDateTime->month() + 1, gregorianDateTime->monthDay(), gregorianDateTime->hour(), gregorianDateTime->minute(), gregorianDateTime->second(), ms); - - memcpy(buf, buffer, charactersWritten); - - ASSERT(charactersWritten > 0 && static_cast(charactersWritten) < sizeof(buffer)); - if (static_cast(charactersWritten) >= sizeof(buffer)) - return -1; - - return charactersWritten; + return static_cast(Bun::toISOString(vm, thisDateObj->internalNumber(), buffer)); } extern "C" int JSC__JSValue__DateNowISOString(JSC::JSGlobalObject* globalObject, char* buf) diff --git a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h index 0d94f7947b..21a4ed1392 100644 --- a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h @@ -73,8 +73,11 @@ public: std::unique_ptr m_clientSubspaceForDOMFormDataIterator; std::unique_ptr m_clientSubspaceForDOMURL; std::unique_ptr m_clientSubspaceForURLSearchParams; - std::unique_ptr m_clientSubspaceForURLSearchParamsIterator; + + std::unique_ptr m_clientSubspaceForCookie; + std::unique_ptr m_clientSubspaceForCookieMap; + std::unique_ptr m_clientSubspaceForCookieMapIterator; std::unique_ptr m_clientSubspaceForExposedToWorkerAndWindow; diff --git a/src/bun.js/bindings/webcore/DOMConstructors.h b/src/bun.js/bindings/webcore/DOMConstructors.h index 89fe962110..6f3dca538d 100644 --- a/src/bun.js/bindings/webcore/DOMConstructors.h +++ b/src/bun.js/bindings/webcore/DOMConstructors.h @@ -855,12 +855,14 @@ enum class DOMConstructorID : uint16_t { XSLTProcessor, // --bun-- + Cookie, + CookieMap, EventEmitter, }; static constexpr unsigned numberOfDOMConstructorsBase = 846; -static constexpr unsigned bunExtraConstructors = 1; +static constexpr unsigned bunExtraConstructors = 3; static constexpr unsigned numberOfDOMConstructors = numberOfDOMConstructorsBase + bunExtraConstructors; diff --git a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h index d1f5efe776..ea657733a0 100644 --- a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h @@ -919,6 +919,10 @@ public: std::unique_ptr m_subspaceForExposedToWorkerAndWindow; std::unique_ptr m_subspaceForURLSearchParams; std::unique_ptr m_subspaceForURLSearchParamsIterator; + + std::unique_ptr m_subspaceForCookie; + std::unique_ptr m_subspaceForCookieMap; + std::unique_ptr m_subspaceForCookieMapIterator; std::unique_ptr m_subspaceForDOMException; // std::unique_ptr m_subspaceForDOMFormData; diff --git a/src/bun.js/bindings/webcore/HTTPParsers.cpp b/src/bun.js/bindings/webcore/HTTPParsers.cpp index 5367ab1f9f..d65cf81915 100644 --- a/src/bun.js/bindings/webcore/HTTPParsers.cpp +++ b/src/bun.js/bindings/webcore/HTTPParsers.cpp @@ -117,12 +117,14 @@ bool isValidReasonPhrase(const String& value) } // See https://fetch.spec.whatwg.org/#concept-header -bool isValidHTTPHeaderValue(const String& value) +bool isValidHTTPHeaderValue(const StringView& value) { + auto length = value.length(); + if (length == 0) return true; UChar c = value[0]; if (isTabOrSpace(c)) return false; - c = value[value.length() - 1]; + c = value[length - 1]; if (isTabOrSpace(c)) return false; if (value.is8Bit()) { @@ -147,7 +149,7 @@ bool isValidHTTPHeaderValue(const String& value) } // See RFC 7231, Section 5.3.2. -bool isValidAcceptHeaderValue(const String& value) +bool isValidAcceptHeaderValue(const StringView& value) { for (unsigned i = 0; i < value.length(); ++i) { UChar c = value[i]; @@ -181,7 +183,7 @@ static bool containsCORSUnsafeRequestHeaderBytes(const String& value) // See RFC 7231, Section 5.3.5 and 3.1.3.2. // https://fetch.spec.whatwg.org/#cors-safelisted-request-header -bool isValidLanguageHeaderValue(const String& value) +bool isValidLanguageHeaderValue(const StringView& value) { for (unsigned i = 0; i < value.length(); ++i) { UChar c = value[i]; @@ -193,7 +195,7 @@ bool isValidLanguageHeaderValue(const String& value) } // See RFC 7230, Section 3.2.6. -bool isValidHTTPToken(const StringView value) +bool isValidHTTPToken(const StringView& value) { if (value.isEmpty()) return false; diff --git a/src/bun.js/bindings/webcore/HTTPParsers.h b/src/bun.js/bindings/webcore/HTTPParsers.h index 05c9aebc55..a128ee94e2 100644 --- a/src/bun.js/bindings/webcore/HTTPParsers.h +++ b/src/bun.js/bindings/webcore/HTTPParsers.h @@ -73,15 +73,15 @@ enum class CrossOriginResourcePolicy : uint8_t { enum class RangeAllowWhitespace : bool { No, Yes }; -bool isValidReasonPhrase(const String&); -bool isValidHTTPHeaderValue(const String&); -bool isValidAcceptHeaderValue(const String&); -bool isValidLanguageHeaderValue(const String&); +bool isValidReasonPhrase(const StringView&); +bool isValidHTTPHeaderValue(const StringView&); +bool isValidAcceptHeaderValue(const StringView&); +bool isValidLanguageHeaderValue(const StringView&); #if USE(GLIB) -WEBCORE_EXPORT bool isValidUserAgentHeaderValue(const String&); +WEBCORE_EXPORT bool isValidUserAgentHeaderValue(const StringView&); #endif -bool isValidHTTPToken(const StringView); -std::optional parseHTTPDate(const String&); +bool isValidHTTPToken(const StringView&); +std::optional parseHTTPDate(const StringView&); StringView filenameFromHTTPContentDisposition(StringView); WEBCORE_EXPORT String extractMIMETypeFromMediaType(const String&); StringView extractCharsetFromMediaType(const String&); diff --git a/src/bun.js/bindings/webcore/JSCookie.cpp b/src/bun.js/bindings/webcore/JSCookie.cpp new file mode 100644 index 0000000000..79fda89359 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSCookie.cpp @@ -0,0 +1,971 @@ +#include "config.h" +#include "JSCookie.h" + +#include "DOMClientIsoSubspaces.h" +#include "DOMIsoSubspaces.h" +#include "ErrorCode.h" +#include "IDLTypes.h" +#include "JSDOMBinding.h" +#include "JSDOMConstructor.h" +#include "JSDOMConvertBase.h" +#include "JSDOMConvertBoolean.h" +#include "JSDOMConvertInterface.h" +#include "JSDOMConvertNullable.h" +#include "JSDOMConvertNumbers.h" +#include "JSDOMConvertStrings.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMGlobalObject.h" +#include "JSDOMGlobalObjectInlines.h" +#include "JSDOMOperation.h" +#include "JSDOMWrapperCache.h" +#include +#include +#include +#include +#include +#include +#include +#include "HTTPParsers.h" +namespace WebCore { + +using namespace JSC; + +// Helper for getting wrapped Cookie from JS value +static Cookie* toCookieWrapped(JSGlobalObject* lexicalGlobalObject, JSC::ThrowScope& throwScope, JSValue value) +{ + auto& vm = getVM(lexicalGlobalObject); + auto* impl = JSCookie::toWrapped(vm, value); + if (UNLIKELY(!impl)) + throwVMTypeError(lexicalGlobalObject, throwScope); + return impl; +} + +static int64_t getExpiresValue(JSGlobalObject* lexicalGlobalObject, JSC::ThrowScope& throwScope, JSValue expiresValue) +{ + if (expiresValue.isUndefined() || expiresValue.isNull()) { + return Cookie::emptyExpiresAtValue; + } + + if (auto* dateInstance = jsDynamicCast(expiresValue)) { + double date = dateInstance->internalNumber(); + if (UNLIKELY(std::isnan(date) || std::isinf(date))) { + throwScope.throwException(lexicalGlobalObject, createRangeError(lexicalGlobalObject, "expires must be a valid Date (or Number)"_s)); + return Cookie::emptyExpiresAtValue; + } + return static_cast(date); + } + + if (expiresValue.isNumber()) { + double expires = expiresValue.asNumber(); + if (UNLIKELY(std::isnan(expires) || !std::isfinite(expires))) { + throwScope.throwException(lexicalGlobalObject, createRangeError(lexicalGlobalObject, "expires must be a valid Number (or Date)"_s)); + return Cookie::emptyExpiresAtValue; + } + + // expires can be a negative number. This is allowed because people do that to force cookie expiration. + return static_cast(expires * 1000); + } + + if (expiresValue.isString()) { + auto expiresStr = convert(*lexicalGlobalObject, expiresValue); + RETURN_IF_EXCEPTION(throwScope, Cookie::emptyExpiresAtValue); + auto nullTerminatedSpan = expiresStr.utf8(); + if (auto parsed = WTF::parseDate(std::span(reinterpret_cast(nullTerminatedSpan.data()), nullTerminatedSpan.length()))) { + if (std::isnan(parsed)) { + throwVMError(lexicalGlobalObject, throwScope, createTypeError(lexicalGlobalObject, "Invalid cookie expiration date"_s)); + return Cookie::emptyExpiresAtValue; + } + return static_cast(parsed); + } else { + throwVMError(lexicalGlobalObject, throwScope, createTypeError(lexicalGlobalObject, "Invalid cookie expiration date"_s)); + return Cookie::emptyExpiresAtValue; + } + } + + return Bun::ERR::INVALID_ARG_VALUE(throwScope, lexicalGlobalObject, "expires"_s, expiresValue, "Invalid expires value. Must be a Date or a number"_s); +} + +template +static std::optional cookieInitFromJS(JSC::VM& vm, JSGlobalObject* lexicalGlobalObject, JSValue options, String& name, String& value) +{ + auto throwScope = DECLARE_THROW_SCOPE(vm); + // Default values + String domain; + String path = "/"_s; + int64_t expires = Cookie::emptyExpiresAtValue; + double maxAge = std::numeric_limits::quiet_NaN(); + bool secure = false; + bool httpOnly = false; + bool partitioned = false; + CookieSameSite sameSite = CookieSameSite::Lax; + auto& names = builtinNames(vm); + + if (!options.isUndefinedOrNull()) { + if (!options.isObject()) { + throwVMTypeError(lexicalGlobalObject, throwScope, "Options must be an object"_s); + return std::nullopt; + } + + if (auto* optionsObj = options.getObject()) { + if (checkName) { + if (auto nameValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, vm.propertyNames->name)) { + name = convert(*lexicalGlobalObject, nameValue); + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + if (name.isEmpty()) { + throwVMTypeError(lexicalGlobalObject, throwScope, "name is required"_s); + return std::nullopt; + } + + if (auto valueValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, vm.propertyNames->value)) { + value = convert(*lexicalGlobalObject, valueValue); + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + } + + // domain + if (auto domainValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.domainPublicName())) { + if (!domainValue.isUndefined() && !domainValue.isNull()) { + domain = convert(*lexicalGlobalObject, domainValue); + } + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + // path + if (auto pathValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.pathPublicName())) { + if (!pathValue.isUndefined() && !pathValue.isNull()) { + path = convert(*lexicalGlobalObject, pathValue); + } + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + // expires + if (auto expiresValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.expiresPublicName())) { + expires = getExpiresValue(lexicalGlobalObject, throwScope, expiresValue); + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + // maxAge + if (auto maxAgeValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.maxAgePublicName())) { + if (!maxAgeValue.isUndefined() && !maxAgeValue.isNull() && maxAgeValue.isNumber()) { + maxAge = maxAgeValue.asNumber(); + } + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + // secure + if (auto secureValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.securePublicName())) { + if (!secureValue.isUndefined()) { + secure = secureValue.toBoolean(lexicalGlobalObject); + } + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + // httpOnly + if (auto httpOnlyValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.httpOnlyPublicName())) { + if (!httpOnlyValue.isUndefined()) { + httpOnly = httpOnlyValue.toBoolean(lexicalGlobalObject); + } + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + // partitioned + if (auto partitionedValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.partitionedPublicName())) { + if (!partitionedValue.isUndefined()) { + partitioned = partitionedValue.toBoolean(lexicalGlobalObject); + } + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + + // sameSite + if (auto sameSiteValue = optionsObj->getIfPropertyExists(lexicalGlobalObject, names.sameSitePublicName())) { + if (!sameSiteValue.isUndefined() && !sameSiteValue.isNull()) { + String sameSiteStr = convert(*lexicalGlobalObject, sameSiteValue); + + if (sameSiteStr == "strict"_s) + sameSite = CookieSameSite::Strict; + else if (sameSiteStr == "lax"_s) + sameSite = CookieSameSite::Lax; + else if (sameSiteStr == "none"_s) + sameSite = CookieSameSite::None; + else + throwVMTypeError(lexicalGlobalObject, throwScope, "Invalid sameSite value. Must be 'strict', 'lax', or 'none'"_s); + } + } + RETURN_IF_EXCEPTION(throwScope, std::nullopt); + } + } + + return CookieInit { name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned }; +} + +std::optional CookieInit::fromJS(JSC::VM& vm, JSGlobalObject* lexicalGlobalObject, JSValue options, String name, String cookieValue) +{ + return cookieInitFromJS(vm, lexicalGlobalObject, options, name, cookieValue); +} + +std::optional CookieInit::fromJS(JSC::VM& vm, JSGlobalObject* lexicalGlobalObject, JSValue options) +{ + WTF::String name; + WTF::String value; + return cookieInitFromJS(vm, lexicalGlobalObject, options, name, value); +} + +static JSC_DECLARE_HOST_FUNCTION(jsCookiePrototypeFunction_toString); +static JSC_DECLARE_HOST_FUNCTION(jsCookiePrototypeFunction_serialize); +static JSC_DECLARE_HOST_FUNCTION(jsCookiePrototypeFunction_toJSON); +static JSC_DECLARE_HOST_FUNCTION(jsCookieStaticFunctionParse); +static JSC_DECLARE_HOST_FUNCTION(jsCookieStaticFunctionFrom); +static JSC_DECLARE_HOST_FUNCTION(jsCookieStaticFunctionSerialize); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_name); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_value); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_value); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_domain); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_domain); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_path); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_path); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_expires); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_expires); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_secure); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_secure); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_sameSite); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_sameSite); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_httpOnly); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_httpOnly); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_maxAge); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_maxAge); +static JSC_DECLARE_CUSTOM_GETTER(jsCookiePrototypeGetter_partitioned); +static JSC_DECLARE_CUSTOM_SETTER(jsCookiePrototypeSetter_partitioned); +static JSC_DECLARE_HOST_FUNCTION(jsCookiePrototypeFunction_isExpired); +static JSC_DECLARE_CUSTOM_GETTER(jsCookieConstructor); + +class JSCookiePrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSCookiePrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSCookiePrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSCookiePrototype(vm, globalObject, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCookiePrototype, Base); + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSCookiePrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; + +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCookiePrototype, JSCookiePrototype::Base); + +JSValue getInternalProperties(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSCookie* castedThis) +{ + return castedThis->wrapped().toJSON(vm, lexicalGlobalObject); +} + +using JSCookieDOMConstructor = JSDOMConstructor; + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSCookieDOMConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* castedThis = jsCast(callFrame->jsCallee()); + + // Check if this was called with 'new' + if (UNLIKELY(!callFrame->thisValue().isObject())) + return throwVMError(lexicalGlobalObject, throwScope, createNotAConstructorError(lexicalGlobalObject, callFrame->jsCallee())); + + // Static method: parse(cookieString) + if (callFrame->argumentCount() == 1 && callFrame->argument(0).isString()) { + // new Bun.Cookie.parse("foo=bar") + auto cookieString = convert(*lexicalGlobalObject, callFrame->argument(0)); + RETURN_IF_EXCEPTION(throwScope, {}); + + if (UNLIKELY(!WebCore::isValidHTTPHeaderValue(cookieString))) { + throwVMTypeError(lexicalGlobalObject, throwScope, "cookie string is not a valid HTTP header value"_s); + RELEASE_AND_RETURN(throwScope, {}); + } + + auto result = Cookie::parse(cookieString); + if (result.hasException()) { + WebCore::propagateException(lexicalGlobalObject, throwScope, result.releaseException()); + } + RETURN_IF_EXCEPTION(throwScope, {}); + auto cookie = result.releaseReturnValue(); + + auto* globalObject = castedThis->globalObject(); + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(lexicalGlobalObject, globalObject, WTFMove(cookie)))); + } else if (callFrame->argumentCount() == 1 && callFrame->argument(0).isObject()) { + // new Bun.Cooke({ + // name: "name", + // value: "value", + // domain: "domain", + // path: "path", + // expires: "expires", + // secure: "secure", + // }) + auto cookieInit = CookieInit::fromJS(vm, lexicalGlobalObject, callFrame->argument(0)); + RETURN_IF_EXCEPTION(throwScope, {}); + ASSERT(cookieInit); + + auto cookie = Cookie::create(*cookieInit); + auto* globalObject = castedThis->globalObject(); + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(lexicalGlobalObject, globalObject, WTFMove(cookie)))); + } else if (callFrame->argumentCount() >= 2) { + // new Bun.Cookie("name", "value", { + // domain: "domain", + // path: "path", + // expires: "expires", + // secure: "secure", + // }) + String name = convert(*lexicalGlobalObject, callFrame->argument(0)); + RETURN_IF_EXCEPTION(throwScope, {}); + + if (name.isEmpty()) { + throwVMTypeError(lexicalGlobalObject, throwScope, "name is required"_s); + RELEASE_AND_RETURN(throwScope, {}); + } + + String value = convert(*lexicalGlobalObject, callFrame->argument(1)); + RETURN_IF_EXCEPTION(throwScope, {}); + + CookieInit cookieInit { name, value }; + + if (callFrame->argumentCount() > 2) { + if (auto updatedCookieInit = CookieInit::fromJS(vm, lexicalGlobalObject, callFrame->argument(2), name, value)) { + cookieInit = *updatedCookieInit; + } + RETURN_IF_EXCEPTION(throwScope, {}); + } + + auto cookie = Cookie::create(cookieInit); + auto* globalObject = castedThis->globalObject(); + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(lexicalGlobalObject, globalObject, WTFMove(cookie)))); + } + + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); +} + +JSC_ANNOTATE_HOST_FUNCTION(JSCookieDOMConstructorConstruct, JSCookieDOMConstructor::construct); + +// Setup for JSCookieDOMConstructor +template<> const ClassInfo JSCookieDOMConstructor::s_info = { "Cookie"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCookieDOMConstructor) }; + +template<> JSValue JSCookieDOMConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) +{ + return globalObject.objectPrototype(); +} + +template<> void JSCookieDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject) +{ + putDirect(vm, vm.propertyNames->length, jsNumber(2), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + JSString* nameString = jsNontrivialString(vm, "Cookie"_s); + m_originalName.set(vm, this, nameString); + putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + putDirect(vm, vm.propertyNames->prototype, JSCookie::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete); + + // Add static methods + JSC::JSFunction* parseFunction = JSC::JSFunction::create(vm, &globalObject, 1, "parse"_s, jsCookieStaticFunctionParse, JSC::ImplementationVisibility::Public, JSC::NoIntrinsic); + putDirect(vm, Identifier::fromString(vm, "parse"_s), parseFunction, static_cast(JSC::PropertyAttribute::DontDelete)); + + JSC::JSFunction* fromFunction = JSC::JSFunction::create(vm, &globalObject, 3, "from"_s, jsCookieStaticFunctionFrom, JSC::ImplementationVisibility::Public, JSC::NoIntrinsic); + putDirect(vm, Identifier::fromString(vm, "from"_s), fromFunction, static_cast(JSC::PropertyAttribute::DontDelete)); +} + +static const HashTableValue JSCookiePrototypeTableValues[] = { + { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookieConstructor, 0 } }, + { "name"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_name, 0 } }, + { "value"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_value, jsCookiePrototypeSetter_value } }, + { "domain"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_domain, jsCookiePrototypeSetter_domain } }, + { "path"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_path, jsCookiePrototypeSetter_path } }, + { "expires"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_expires, jsCookiePrototypeSetter_expires } }, + { "maxAge"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_maxAge, jsCookiePrototypeSetter_maxAge } }, + { "secure"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_secure, jsCookiePrototypeSetter_secure } }, + { "httpOnly"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_httpOnly, jsCookiePrototypeSetter_httpOnly } }, + { "sameSite"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_sameSite, jsCookiePrototypeSetter_sameSite } }, + { "partitioned"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookiePrototypeGetter_partitioned, jsCookiePrototypeSetter_partitioned } }, + { "isExpired"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookiePrototypeFunction_isExpired, 0 } }, + { "toString"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookiePrototypeFunction_toString, 0 } }, + { "toJSON"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookiePrototypeFunction_toJSON, 0 } }, + { "serialize"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookiePrototypeFunction_serialize, 0 } }, +}; + +const ClassInfo JSCookiePrototype::s_info = { "Cookie"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCookiePrototype) }; + +void JSCookiePrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSCookie::info(), JSCookiePrototypeTableValues, *this); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +const ClassInfo JSCookie::s_info = { "Cookie"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCookie) }; + +JSCookie::JSCookie(Structure* structure, JSDOMGlobalObject& globalObject, Ref&& impl) + : JSDOMWrapper(structure, globalObject, WTFMove(impl)) +{ +} + +void JSCookie::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); + + m_expires.setMayBeNull(vm, this, nullptr); +} + +JSObject* JSCookie::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + auto* structure = JSCookiePrototype::createStructure(vm, &globalObject, globalObject.objectPrototype()); + structure->setMayBePrototype(true); + return JSCookiePrototype::create(vm, &globalObject, structure); +} + +JSObject* JSCookie::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +JSValue JSCookie::getConstructor(VM& vm, const JSGlobalObject* globalObject) +{ + return getDOMConstructor(vm, *jsCast(globalObject)); +} + +void JSCookie::destroy(JSC::JSCell* cell) +{ + JSCookie* thisObject = static_cast(cell); + thisObject->JSCookie::~JSCookie(); +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookieConstructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* prototype = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!prototype)) + return throwVMTypeError(lexicalGlobalObject, throwScope); + return JSValue::encode(JSCookie::getConstructor(vm, prototype->globalObject())); +} + +// Instance methods +static inline JSC::EncodedJSValue jsCookiePrototypeFunction_toStringBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.toString(vm)))); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookiePrototypeFunction_toString, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "toString"); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookiePrototypeFunction_serialize, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "serialize"); +} + +// Implementation of the toJSON method +static inline JSC::EncodedJSValue jsCookiePrototypeFunction_toJSONBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + // Delegate to the C++ toJSON method + JSC::JSValue result = impl.toJSON(vm, lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + return JSValue::encode(result); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookiePrototypeFunction_toJSON, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "toJSON"); +} + +// Static function implementations +JSC_DEFINE_HOST_FUNCTION(jsCookieStaticFunctionParse, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + + if (callFrame->argumentCount() < 1) + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + + auto cookieString = convert(*lexicalGlobalObject, callFrame->uncheckedArgument(0)); + RETURN_IF_EXCEPTION(throwScope, {}); + + if (cookieString.isEmpty()) { + return JSValue::encode(toJSNewlyCreated(lexicalGlobalObject, defaultGlobalObject(lexicalGlobalObject), Cookie::create(CookieInit {}))); + } + + if (UNLIKELY(!WebCore::isValidHTTPHeaderValue(cookieString))) { + throwVMTypeError(lexicalGlobalObject, throwScope, "cookie string is not a valid HTTP header value"_s); + RELEASE_AND_RETURN(throwScope, {}); + } + + auto result = Cookie::parse(cookieString); + if (result.hasException()) { + WebCore::propagateException(lexicalGlobalObject, throwScope, result.releaseException()); + } + + RETURN_IF_EXCEPTION(throwScope, {}); + + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJSNewlyCreated(lexicalGlobalObject, globalObject, result.releaseReturnValue()))); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieStaticFunctionFrom, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + + if (callFrame->argumentCount() < 2) + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + + auto name = convert(*lexicalGlobalObject, callFrame->uncheckedArgument(0)); + RETURN_IF_EXCEPTION(throwScope, {}); + + if (name.isEmpty()) { + throwVMTypeError(lexicalGlobalObject, throwScope, "name is required"_s); + return {}; + } + + auto value = convert(*lexicalGlobalObject, callFrame->uncheckedArgument(1)); + RETURN_IF_EXCEPTION(throwScope, {}); + + CookieInit cookieInit { name, value }; + JSValue optionsValue = callFrame->argument(2); + // Check for options object + if (!optionsValue.isUndefinedOrNull() && optionsValue.isObject()) { + if (auto updatedCookieInit = CookieInit::fromJS(vm, lexicalGlobalObject, optionsValue, name, value)) { + cookieInit = *updatedCookieInit; + } + RETURN_IF_EXCEPTION(throwScope, {}); + } + + auto cookie = Cookie::create(cookieInit); + auto* globalObject = jsCast(lexicalGlobalObject); + return JSValue::encode(toJSNewlyCreated(lexicalGlobalObject, globalObject, WTFMove(cookie))); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieStaticFunctionSerialize, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + + if (callFrame->argumentCount() < 1) + return JSValue::encode(jsEmptyString(vm)); + + Vector> cookies; + + // Process each cookie argument + for (unsigned i = 0; i < callFrame->argumentCount(); i++) { + auto* cookieImpl = toCookieWrapped(lexicalGlobalObject, throwScope, callFrame->uncheckedArgument(i)); + RETURN_IF_EXCEPTION(throwScope, {}); + + if (cookieImpl) + cookies.append(*cookieImpl); + } + + // Let the C++ Cookie::serialize handle the work + String result = Cookie::serialize(vm, cookies); + + return JSValue::encode(jsString(vm, result)); +} + +// Property getters/setters +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_name, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "name"_s); + auto& impl = thisObject->wrapped(); + return JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.name())); +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_value, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "value"_s); + auto& impl = thisObject->wrapped(); + return JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.value())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_value, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "value"_s); + auto& impl = thisObject->wrapped(); + auto value = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setValue(value); + return true; +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_domain, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "domain"_s); + auto& impl = thisObject->wrapped(); + return JSValue::encode(toJS>(*lexicalGlobalObject, throwScope, impl.domain())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_domain, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "domain"_s); + auto& impl = thisObject->wrapped(); + auto value = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setDomain(value); + return true; +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_path, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "path"_s); + auto& impl = thisObject->wrapped(); + return JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.path())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_path, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "path"_s); + auto& impl = thisObject->wrapped(); + auto value = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setPath(value); + return true; +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_expires, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "expires"_s); + auto& impl = thisObject->wrapped(); + if (impl.hasExpiry()) { + if (thisObject->m_expires) { + auto* dateInstance = thisObject->m_expires.get(); + if (static_cast(dateInstance->internalNumber()) == impl.expires()) { + return JSValue::encode(dateInstance); + } + } + auto* dateInstance = JSC::DateInstance::create(vm, lexicalGlobalObject->dateStructure(), impl.expires()); + thisObject->m_expires.set(vm, thisObject, dateInstance); + return JSValue::encode(dateInstance); + } + + return JSValue::encode(JSC::jsUndefined()); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_expires, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "expires"_s); + auto& impl = thisObject->wrapped(); + auto value = getExpiresValue(lexicalGlobalObject, throwScope, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setExpires(value); + thisObject->m_expires.clear(); + return true; +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_secure, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "secure"_s); + auto& impl = thisObject->wrapped(); + return JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.secure())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_secure, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "secure"_s); + auto& impl = thisObject->wrapped(); + auto value = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setSecure(value); + return true; +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_sameSite, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "sameSite"_s); + auto& impl = thisObject->wrapped(); + + return JSValue::encode(toJS(lexicalGlobalObject, impl.sameSite())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_sameSite, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "sameSite"_s); + auto& impl = thisObject->wrapped(); + + auto sameSiteStr = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + + CookieSameSite sameSite; + if (WTF::equalIgnoringASCIICase(sameSiteStr, "strict"_s)) + sameSite = CookieSameSite::Strict; + else if (WTF::equalIgnoringASCIICase(sameSiteStr, "lax"_s)) + sameSite = CookieSameSite::Lax; + else if (WTF::equalIgnoringASCIICase(sameSiteStr, "none"_s)) + sameSite = CookieSameSite::None; + else { + throwTypeError(lexicalGlobalObject, throwScope, "Invalid sameSite value. Must be 'strict', 'lax', or 'none'"_s); + return false; + } + + impl.setSameSite(sameSite); + return true; +} + +// HttpOnly property +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_httpOnly, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "httpOnly"_s); + auto& impl = thisObject->wrapped(); + return JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.httpOnly())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_httpOnly, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "httpOnly"_s); + auto& impl = thisObject->wrapped(); + auto value = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setHttpOnly(value); + return true; +} + +// MaxAge property +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_maxAge, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "maxAge"_s); + auto& impl = thisObject->wrapped(); + double maxAge = impl.maxAge(); + if (std::isnan(maxAge)) + return JSValue::encode(jsUndefined()); + return JSValue::encode(toJS>(*lexicalGlobalObject, throwScope, maxAge)); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_maxAge, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "maxAge"_s); + auto& impl = thisObject->wrapped(); + if (JSValue::decode(encodedValue).isUndefinedOrNull()) { + impl.setMaxAge(std::numeric_limits::quiet_NaN()); + return true; + } + auto value = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setMaxAge(value); + + return true; +} + +// Partitioned property +JSC_DEFINE_CUSTOM_GETTER(jsCookiePrototypeGetter_partitioned, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "partitioned"_s); + auto& impl = thisObject->wrapped(); + return JSValue::encode(toJS(*lexicalGlobalObject, throwScope, impl.partitioned())); +} + +JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_partitioned, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue encodedValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwThisTypeError(*lexicalGlobalObject, throwScope, "Cookie"_s, "partitioned"_s); + auto& impl = thisObject->wrapped(); + auto value = convert(*lexicalGlobalObject, JSValue::decode(encodedValue)); + RETURN_IF_EXCEPTION(throwScope, false); + impl.setPartitioned(value); + return true; +} + +// isExpired method +static inline JSC::EncodedJSValue jsCookiePrototypeFunction_isExpiredBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + bool expired = impl.isExpired(); + return JSValue::encode(JSC::jsBoolean(expired)); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookiePrototypeFunction_isExpired, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "isExpired"); +} + +GCClient::IsoSubspace* JSCookie::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForCookie.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForCookie = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForCookie.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForCookie = std::forward(space); }); +} + +void JSCookie::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) +{ + auto* thisObject = jsCast(cell); + analyzer.setWrappedObjectForCell(cell, &thisObject->wrapped()); + Base::analyzeHeap(cell, analyzer); +} + +bool JSCookieOwner::isReachableFromOpaqueRoots(JSC::Handle handle, void*, AbstractSlotVisitor& visitor, ASCIILiteral* reason) +{ + UNUSED_PARAM(handle); + UNUSED_PARAM(visitor); + UNUSED_PARAM(reason); + return false; +} + +DEFINE_VISIT_CHILDREN(JSCookie); + +template +void JSCookie::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSCookie* thisObject = jsCast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + + visitor.append(thisObject->m_expires); +} + +void JSCookieOwner::finalize(JSC::Handle handle, void* context) +{ + auto* jsCookie = static_cast(handle.slot()->asCell()); + auto& world = *static_cast(context); + uncacheWrapper(world, &jsCookie->wrapped(), jsCookie); +} + +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref&& impl) +{ + return createWrapper(globalObject, WTFMove(impl)); +} + +JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Cookie& impl) +{ + return wrap(lexicalGlobalObject, globalObject, impl); +} + +Cookie* JSCookie::toWrapped(JSC::VM& vm, JSC::JSValue value) +{ + if (auto* wrapper = jsDynamicCast(value)) + return &wrapper->wrapped(); + return nullptr; +} + +size_t JSCookie::estimatedSize(JSC::JSCell* cell, JSC::VM& vm) +{ + auto* thisObject = jsCast(cell); + auto& wrapped = thisObject->wrapped(); + return Base::estimatedSize(cell, vm) + wrapped.memoryCost(); +} + +JSC::JSValue toJS(JSC::JSGlobalObject* globalObject, CookieSameSite sameSite) +{ + auto& commonStrings = defaultGlobalObject(globalObject)->commonStrings(); + switch (sameSite) { + case CookieSameSite::Strict: + return commonStrings.strictString(globalObject); + case CookieSameSite::Lax: + return commonStrings.laxString(globalObject); + case CookieSameSite::None: + return commonStrings.noneString(globalObject); + default: { + break; + } + } + __builtin_unreachable(); + return {}; +} +} diff --git a/src/bun.js/bindings/webcore/JSCookie.h b/src/bun.js/bindings/webcore/JSCookie.h new file mode 100644 index 0000000000..f2f893bf25 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSCookie.h @@ -0,0 +1,78 @@ +#pragma once + +#include "JSDOMWrapper.h" +#include "Cookie.h" +#include +#include +namespace WebCore { + +class JSCookie : public JSDOMWrapper { +public: + using Base = JSDOMWrapper; + static JSCookie* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject, Ref&& impl) + { + JSCookie* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSCookie(structure, *globalObject, WTFMove(impl)); + ptr->finishCreation(globalObject->vm()); + return ptr; + } + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + static Cookie* toWrapped(JSC::VM&, JSC::JSValue); + static void destroy(JSC::JSCell*); + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + mutable WriteBarrier m_expires; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(static_cast(WebCore::JSAsJSONType), StructureFlags), info()); + } + + static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); + static size_t estimatedSize(JSC::JSCell* cell, JSC::VM& vm); + +protected: + JSCookie(JSC::Structure*, JSDOMGlobalObject&, Ref&&); + + void finishCreation(JSC::VM&); +}; + +class JSCookieOwner final : public JSC::WeakHandleOwner { +public: + bool isReachableFromOpaqueRoots(JSC::Handle, void* context, JSC::AbstractSlotVisitor&, ASCIILiteral*) final; + void finalize(JSC::Handle, void* context) final; +}; + +inline JSC::WeakHandleOwner* wrapperOwner(DOMWrapperWorld&, Cookie*) +{ + static NeverDestroyed owner; + return &owner.get(); +} + +inline void* wrapperKey(Cookie* wrappableObject) +{ + return wrappableObject; +} +JSC::JSValue getInternalProperties(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSCookie* castedThis); +JSC::JSValue toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, Cookie&); +inline JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Cookie* impl) { return impl ? toJS(lexicalGlobalObject, globalObject, *impl) : JSC::jsNull(); } +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject*, Ref&&); +inline JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, RefPtr&& impl) { return impl ? toJSNewlyCreated(lexicalGlobalObject, globalObject, impl.releaseNonNull()) : JSC::jsNull(); } + +template<> struct JSDOMWrapperConverterTraits { + using WrapperClass = JSCookie; + using ToWrappedReturnType = Cookie*; +}; + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSCookieMap.cpp b/src/bun.js/bindings/webcore/JSCookieMap.cpp new file mode 100644 index 0000000000..5de3a816d2 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSCookieMap.cpp @@ -0,0 +1,667 @@ +#include "config.h" +#include "JSCookieMap.h" + +#include "Cookie.h" +#include "DOMClientIsoSubspaces.h" +#include "DOMIsoSubspaces.h" +#include "IDLTypes.h" +#include "JSCookie.h" +#include "JSDOMBinding.h" +#include "JSDOMConstructor.h" +#include "JSDOMConvertBase.h" +#include "JSDOMConvertBoolean.h" +#include "JSDOMConvertDate.h" +#include "JSDOMConvertInterface.h" +#include "JSDOMConvertNullable.h" +#include "JSDOMConvertRecord.h" +#include "JSDOMConvertSequences.h" +#include "JSDOMConvertStrings.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMGlobalObject.h" +#include "JSDOMGlobalObjectInlines.h" +#include "JSDOMIterator.h" +#include "JSDOMOperation.h" +#include "JSDOMWrapperCache.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "HTTPParsers.h" +namespace WebCore { + +using namespace JSC; + +// Define the toWrapped template function for CookieMap +template +CookieMap* toWrapped(JSGlobalObject& lexicalGlobalObject, ExceptionThrower&& exceptionThrower, JSValue value) +{ + auto& vm = getVM(&lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* impl = JSCookieMap::toWrapped(vm, value); + if (UNLIKELY(!impl)) + exceptionThrower(lexicalGlobalObject, scope); + return impl; +} + +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_get); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_toSetCookieHeaders); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_has); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_set); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_delete); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_toJSON); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_entries); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_keys); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_values); +static JSC_DECLARE_HOST_FUNCTION(jsCookieMapPrototypeFunction_forEach); +static JSC_DECLARE_CUSTOM_GETTER(jsCookieMapPrototypeGetter_size); +static JSC_DECLARE_CUSTOM_GETTER(jsCookieMapConstructor); + +class JSCookieMapPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static JSCookieMapPrototype* create(JSC::VM& vm, JSDOMGlobalObject* globalObject, JSC::Structure* structure) + { + JSCookieMapPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSCookieMapPrototype(vm, globalObject, structure); + ptr->finishCreation(vm); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCookieMapPrototype, Base); + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSCookieMapPrototype(JSC::VM& vm, JSC::JSGlobalObject*, JSC::Structure* structure) + : JSC::JSNonFinalObject(vm, structure) + { + } + + void finishCreation(JSC::VM&); +}; + +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSCookieMapPrototype, JSCookieMapPrototype::Base); + +using JSCookieMapDOMConstructor = JSDOMConstructor; + +template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSCookieMapDOMConstructor::construct(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* castedThis = jsCast(callFrame->jsCallee()); + + // Check arguments + JSValue initValue = callFrame->argument(0); + + std::variant>, HashMap, String> init; + + if (initValue.isUndefinedOrNull() || (initValue.isString() && initValue.getString(lexicalGlobalObject).isEmpty())) { + init = String(); + } else if (initValue.isString()) { + init = initValue.getString(lexicalGlobalObject); + } else if (initValue.isObject()) { + auto* object = initValue.getObject(); + + if (isArray(lexicalGlobalObject, object)) { + auto* array = jsCast(object); + Vector> seqSeq; + + uint32_t length = array->length(); + for (uint32_t i = 0; i < length; ++i) { + auto element = array->getIndex(lexicalGlobalObject, i); + RETURN_IF_EXCEPTION(throwScope, {}); + + if (!element.isObject() || !jsDynamicCast(element)) { + throwTypeError(lexicalGlobalObject, throwScope, "Expected each element to be an array of two strings"_s); + return {}; + } + + auto* subArray = jsCast(element); + if (subArray->length() != 2) { + throwTypeError(lexicalGlobalObject, throwScope, "Expected arrays of exactly two strings"_s); + return {}; + } + + auto first = subArray->getIndex(lexicalGlobalObject, 0); + RETURN_IF_EXCEPTION(throwScope, {}); + auto second = subArray->getIndex(lexicalGlobalObject, 1); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto firstStr = first.toString(lexicalGlobalObject)->value(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + auto secondStr = second.toString(lexicalGlobalObject)->value(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + Vector pair; + pair.append(firstStr); + pair.append(secondStr); + seqSeq.append(WTFMove(pair)); + } + init = WTFMove(seqSeq); + } else { + // Handle as record + HashMap record; + + PropertyNameArray propertyNames(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + JSObject::getOwnPropertyNames(object, lexicalGlobalObject, propertyNames, DontEnumPropertiesMode::Include); + RETURN_IF_EXCEPTION(throwScope, {}); + + for (const auto& propertyName : propertyNames) { + JSValue value = object->get(lexicalGlobalObject, propertyName); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto valueStr = value.toString(lexicalGlobalObject)->value(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + record.set(propertyName.string(), valueStr); + } + init = WTFMove(record); + } + } else { + throwTypeError(lexicalGlobalObject, throwScope, "Invalid initializer type"_s); + return {}; + } + + auto result = CookieMap::create(WTFMove(init)); + RETURN_IF_EXCEPTION(throwScope, {}); + + RELEASE_AND_RETURN(throwScope, JSValue::encode(toJSNewlyCreated(lexicalGlobalObject, castedThis->globalObject(), result.releaseReturnValue()))); +} + +JSC_ANNOTATE_HOST_FUNCTION(JSCookieMapDOMConstructorConstruct, JSCookieMapDOMConstructor::construct); + +template<> const ClassInfo JSCookieMapDOMConstructor::s_info = { "CookieMap"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCookieMapDOMConstructor) }; + +template<> JSValue JSCookieMapDOMConstructor::prototypeForStructure(JSC::VM& vm, const JSDOMGlobalObject& globalObject) +{ + return globalObject.objectPrototype(); +} + +template<> void JSCookieMapDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject) +{ + putDirect(vm, vm.propertyNames->length, jsNumber(1), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + JSString* nameString = jsNontrivialString(vm, "CookieMap"_s); + m_originalName.set(vm, this, nameString); + putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + putDirect(vm, vm.propertyNames->prototype, JSCookieMap::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete); +} + +static const HashTableValue JSCookieMapPrototypeTableValues[] = { + { "constructor"_s, static_cast(JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookieMapConstructor, 0 } }, + { "get"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_get, 1 } }, + { "toSetCookieHeaders"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_toSetCookieHeaders, 0 } }, + { "has"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_has, 1 } }, + { "set"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_set, 2 } }, + { "delete"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_delete, 1 } }, + { "entries"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_entries, 0 } }, + { "keys"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_keys, 0 } }, + { "values"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_values, 0 } }, + { "forEach"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_forEach, 1 } }, + { "toJSON"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsCookieMapPrototypeFunction_toJSON, 0 } }, + { "size"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsCookieMapPrototypeGetter_size, 0 } }, +}; + +const ClassInfo JSCookieMapPrototype::s_info = { "CookieMap"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCookieMapPrototype) }; + +void JSCookieMapPrototype::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSCookieMap::info(), JSCookieMapPrototypeTableValues, *this); + putDirect(vm, vm.propertyNames->iteratorSymbol, getDirect(vm, PropertyName(Identifier::fromString(vm, "entries"_s))), static_cast(JSC::PropertyAttribute::DontEnum)); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +const ClassInfo JSCookieMap::s_info = { "CookieMap"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSCookieMap) }; + +JSCookieMap::JSCookieMap(Structure* structure, JSDOMGlobalObject& globalObject, Ref&& impl) + : JSDOMWrapper(structure, globalObject, WTFMove(impl)) +{ +} + +void JSCookieMap::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); +} + +JSObject* JSCookieMap::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + auto* structure = JSCookieMapPrototype::createStructure(vm, &globalObject, globalObject.objectPrototype()); + structure->setMayBePrototype(true); + return JSCookieMapPrototype::create(vm, &globalObject, structure); +} + +JSObject* JSCookieMap::prototype(VM& vm, JSDOMGlobalObject& globalObject) +{ + return getDOMPrototype(vm, globalObject); +} + +JSValue JSCookieMap::getConstructor(VM& vm, const JSGlobalObject* globalObject) +{ + return getDOMConstructor(vm, *jsCast(globalObject)); +} + +void JSCookieMap::destroy(JSC::JSCell* cell) +{ + JSCookieMap* thisObject = static_cast(cell); + thisObject->JSCookieMap::~JSCookieMap(); +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookieMapConstructor, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* prototype = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!prototype)) + return throwVMTypeError(lexicalGlobalObject, throwScope); + return JSValue::encode(JSCookieMap::getConstructor(JSC::getVM(lexicalGlobalObject), prototype->globalObject())); +} + +JSC_DEFINE_CUSTOM_GETTER(jsCookieMapPrototypeGetter_size, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = jsDynamicCast(JSValue::decode(thisValue)); + if (UNLIKELY(!thisObject)) + return throwVMTypeError(lexicalGlobalObject, throwScope); + return JSValue::encode(jsNumber(thisObject->wrapped().size())); +} + +// Implementation of the get method +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_getBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + if (callFrame->argumentCount() < 1) + return JSValue::encode(jsNull()); + + JSValue arg0 = callFrame->uncheckedArgument(0); + + // Handle single string argument (name) + auto name = convert(*lexicalGlobalObject, arg0); + RETURN_IF_EXCEPTION(throwScope, {}); + + std::optional value = impl.get(name); + if (!value.has_value()) + return JSValue::encode(jsNull()); + + // Return as Cookie object + return JSValue::encode(jsString(vm, value.value())); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_get, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "get"); +} + +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_toSetCookieHeadersBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + auto cookies = impl.getAllChanges(); + JSC::JSArray* resultArray = JSC::constructEmptyArray(lexicalGlobalObject, nullptr, cookies.size()); + RETURN_IF_EXCEPTION(throwScope, {}); + size_t i = 0; + for (auto& item : cookies) { + resultArray->putDirectIndex(lexicalGlobalObject, i, jsString(vm, item->toString(vm))); + RETURN_IF_EXCEPTION(throwScope, {}); + i += 1; + } + + return JSValue::encode(resultArray); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_toSetCookieHeaders, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "toSetCookieHeaders"); +} + +// Implementation of the has method +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_hasBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + if (callFrame->argumentCount() < 1) + return JSValue::encode(jsBoolean(false)); + + auto name = convert(*lexicalGlobalObject, callFrame->uncheckedArgument(0)); + RETURN_IF_EXCEPTION(throwScope, {}); + + return JSValue::encode(jsBoolean(impl.has(name))); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_has, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "has"); +} + +// Implementation of the set method +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_setBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + if (callFrame->argumentCount() < 1) + return JSValue::encode(jsUndefined()); + + JSValue arg0 = callFrame->uncheckedArgument(0); + JSValue arg2 = callFrame->argument(2); + + CookieInit cookieInit = {}; + + // Check if we're setting with a Cookie object directly + if (arg0.isObject() && JSCookie::toWrapped(vm, arg0)) { + auto* cookieImpl = JSCookie::toWrapped(vm, arg0); + if (cookieImpl) + impl.set(Ref(*cookieImpl)); + return JSValue::encode(jsUndefined()); + } else if (arg0.isObject()) { + auto* obj = arg0.getObject(); + if (auto updatedCookieInit = CookieInit::fromJS(vm, lexicalGlobalObject, obj)) { + cookieInit = *updatedCookieInit; + } + RETURN_IF_EXCEPTION(throwScope, {}); + } else { + // Handle name/value pair + if (callFrame->argumentCount() < 2) + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + + cookieInit.name = convert(*lexicalGlobalObject, callFrame->uncheckedArgument(0)); + RETURN_IF_EXCEPTION(throwScope, {}); + + cookieInit.value = convert(*lexicalGlobalObject, callFrame->uncheckedArgument(1)); + RETURN_IF_EXCEPTION(throwScope, {}); + + // Check for optional third parameter (options) + if (callFrame->argumentCount() >= 3) { + JSValue optionsArg = arg2; + if (auto updatedCookieInit = CookieInit::fromJS(vm, lexicalGlobalObject, optionsArg, cookieInit.name, cookieInit.value)) { + cookieInit = *updatedCookieInit; + } + RETURN_IF_EXCEPTION(throwScope, {}); + } + } + + auto cookie = Cookie::create(cookieInit); + impl.set(WTFMove(cookie)); + + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_set, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "set"); +} + +// Implementation of the delete method +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_deleteBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + if (callFrame->argumentCount() < 1) + return JSValue::encode(jsUndefined()); + + JSValue arg0 = callFrame->uncheckedArgument(0); + auto& names = builtinNames(vm); + + JSValue nameArg = jsUndefined(); + JSValue optionsArg = jsUndefined(); + if (arg0.isObject()) { + optionsArg = arg0; + } else { + nameArg = arg0; + if (callFrame->argumentCount() >= 2) { + optionsArg = callFrame->uncheckedArgument(1); + if (!optionsArg.isObject()) { + return throwVMError(lexicalGlobalObject, throwScope, createTypeError(lexicalGlobalObject, "Options must be an object"_s)); + } + } + } + + CookieStoreDeleteOptions deleteOptions; + deleteOptions.path = "/"_s; + JSValue nameValue = nameArg; + if (optionsArg.isObject()) { + auto* options = optionsArg.getObject(); + + // Extract name + if (nameValue.isUndefined()) nameValue = options->getIfPropertyExists(lexicalGlobalObject, PropertyName(vm.propertyNames->name)); + + // Extract optional domain + if (auto domainValue = options->getIfPropertyExists(lexicalGlobalObject, names.domainPublicName())) { + RETURN_IF_EXCEPTION(throwScope, {}); + + if (!domainValue.isUndefined() && !domainValue.isNull()) { + deleteOptions.domain = convert(*lexicalGlobalObject, domainValue); + RETURN_IF_EXCEPTION(throwScope, {}); + } + } + + // Extract optional path + if (auto pathValue = options->getIfPropertyExists(lexicalGlobalObject, names.pathPublicName())) { + RETURN_IF_EXCEPTION(throwScope, {}); + + if (!pathValue.isUndefined() && !pathValue.isNull()) { + deleteOptions.path = convert(*lexicalGlobalObject, pathValue); + RETURN_IF_EXCEPTION(throwScope, {}); + } + } + } + + if (nameValue.isString()) { + RETURN_IF_EXCEPTION(throwScope, {}); + + if (!nameValue.isUndefined() && !nameValue.isNull()) { + deleteOptions.name = convert(*lexicalGlobalObject, nameValue); + } + + RETURN_IF_EXCEPTION(throwScope, {}); + } else { + return throwVMError(lexicalGlobalObject, throwScope, createTypeError(lexicalGlobalObject, "Cookie name is required"_s)); + } + + impl.remove(deleteOptions); + + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_delete, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "delete"); +} + +// Implementation of the toJSON method +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_toJSONBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + + // Delegate to the C++ toJSON method + JSC::JSValue result = impl.toJSON(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + return JSValue::encode(result); +} + +JSC::JSValue getInternalProperties(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSCookieMap* castedThis) +{ + return castedThis->wrapped().toJSON(lexicalGlobalObject); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_toJSON, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "toJSON"); +} + +// Iterator implementation for CookieMap +struct CookieMapIteratorTraits { + static constexpr JSDOMIteratorType type = JSDOMIteratorType::Map; + using KeyType = IDLUSVString; + using ValueType = IDLUSVString; +}; + +using CookieMapIteratorBase = JSDOMIteratorBase; +class CookieMapIterator final : public CookieMapIteratorBase { +public: + using Base = CookieMapIteratorBase; + DECLARE_INFO; + + 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_clientSubspaceForCookieMapIterator.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForCookieMapIterator = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForCookieMapIterator.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForCookieMapIterator = std::forward(space); }); + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static CookieMapIterator* create(JSC::VM& vm, JSC::Structure* structure, JSCookieMap& iteratedObject, IterationKind kind) + { + auto* instance = new (NotNull, JSC::allocateCell(vm)) CookieMapIterator(structure, iteratedObject, kind); + instance->finishCreation(vm); + return instance; + } + +private: + CookieMapIterator(JSC::Structure* structure, JSCookieMap& iteratedObject, IterationKind kind) + : Base(structure, iteratedObject, kind) + { + } +}; + +using CookieMapIteratorPrototype = JSDOMIteratorPrototype; +JSC_ANNOTATE_HOST_FUNCTION(CookieMapIteratorPrototypeNext, CookieMapIteratorPrototype::next); + +template<> +const JSC::ClassInfo CookieMapIteratorBase::s_info = { "CookieMap Iterator"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(CookieMapIteratorBase) }; +const JSC::ClassInfo CookieMapIterator::s_info = { "CookieMap Iterator"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(CookieMapIterator) }; + +template<> +const JSC::ClassInfo CookieMapIteratorPrototype::s_info = { "CookieMap Iterator"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(CookieMapIteratorPrototype) }; + +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_entriesCaller(JSGlobalObject*, CallFrame*, JSCookieMap* thisObject) +{ + return JSValue::encode(iteratorCreate(*thisObject, IterationKind::Entries)); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_entries, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "entries"); +} + +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_keysCaller(JSGlobalObject*, CallFrame*, JSCookieMap* thisObject) +{ + return JSValue::encode(iteratorCreate(*thisObject, IterationKind::Keys)); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_keys, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "keys"); +} + +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_valuesCaller(JSGlobalObject*, CallFrame*, JSCookieMap* thisObject) +{ + return JSValue::encode(iteratorCreate(*thisObject, IterationKind::Values)); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_values, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "values"); +} + +static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_forEachCaller(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame, JSCookieMap* thisObject) +{ + return JSValue::encode(iteratorForEach(*lexicalGlobalObject, *callFrame, *thisObject)); +} + +JSC_DEFINE_HOST_FUNCTION(jsCookieMapPrototypeFunction_forEach, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "forEach"); +} + +GCClient::IsoSubspace* JSCookieMap::subspaceForImpl(VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForCookieMap.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForCookieMap = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForCookieMap.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForCookieMap = std::forward(space); }); +} + +void JSCookieMap::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) +{ + auto* thisObject = jsCast(cell); + analyzer.setWrappedObjectForCell(cell, &thisObject->wrapped()); + Base::analyzeHeap(cell, analyzer); +} + +bool JSCookieMapOwner::isReachableFromOpaqueRoots(JSC::Handle handle, void*, AbstractSlotVisitor& visitor, ASCIILiteral* reason) +{ + UNUSED_PARAM(handle); + UNUSED_PARAM(visitor); + UNUSED_PARAM(reason); + return false; +} + +void JSCookieMapOwner::finalize(JSC::Handle handle, void* context) +{ + auto* jsCookieMap = static_cast(handle.slot()->asCell()); + auto& world = *static_cast(context); + uncacheWrapper(world, &jsCookieMap->wrapped(), jsCookieMap); +} + +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObject, Ref&& impl) +{ + return createWrapper(globalObject, WTFMove(impl)); +} + +JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, CookieMap& impl) +{ + return wrap(lexicalGlobalObject, globalObject, impl); +} + +CookieMap* JSCookieMap::toWrapped(JSC::VM& vm, JSC::JSValue value) +{ + if (auto* wrapper = jsDynamicCast(value)) + return &wrapper->wrapped(); + return nullptr; +} + +size_t JSCookieMap::estimatedSize(JSC::JSCell* cell, JSC::VM& vm) +{ + auto* thisObject = jsCast(cell); + auto& wrapped = thisObject->wrapped(); + return Base::estimatedSize(cell, vm) + wrapped.memoryCost(); +} + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSCookieMap.h b/src/bun.js/bindings/webcore/JSCookieMap.h new file mode 100644 index 0000000000..9b16ffcc19 --- /dev/null +++ b/src/bun.js/bindings/webcore/JSCookieMap.h @@ -0,0 +1,75 @@ +#pragma once + +#include "JSDOMWrapper.h" +#include "CookieMap.h" +#include + +namespace WebCore { + +class JSCookieMap : public JSDOMWrapper { +public: + using Base = JSDOMWrapper; + static JSCookieMap* create(JSC::Structure* structure, JSDOMGlobalObject* globalObject, Ref&& impl) + { + JSCookieMap* ptr = new (NotNull, JSC::allocateCell(globalObject->vm())) JSCookieMap(structure, *globalObject, WTFMove(impl)); + ptr->finishCreation(globalObject->vm()); + return ptr; + } + + static JSC::JSObject* createPrototype(JSC::VM&, JSDOMGlobalObject&); + static JSC::JSObject* prototype(JSC::VM&, JSDOMGlobalObject&); + static CookieMap* toWrapped(JSC::VM&, JSC::JSValue); + static void destroy(JSC::JSCell*); + + DECLARE_INFO; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(static_cast(WebCore::JSAsJSONType), StructureFlags), info()); + } + + static JSC::JSValue getConstructor(JSC::VM&, const JSC::JSGlobalObject*); + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return subspaceForImpl(vm); + } + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); + static size_t estimatedSize(JSC::JSCell* cell, JSC::VM& vm); + +protected: + JSCookieMap(JSC::Structure*, JSDOMGlobalObject&, Ref&&); + + void finishCreation(JSC::VM&); +}; +JSC::JSValue getInternalProperties(JSC::VM& vm, JSC::JSGlobalObject* lexicalGlobalObject, JSCookieMap* castedThis); +class JSCookieMapOwner final : public JSC::WeakHandleOwner { +public: + bool isReachableFromOpaqueRoots(JSC::Handle, void* context, JSC::AbstractSlotVisitor&, ASCIILiteral*) final; + void finalize(JSC::Handle, void* context) final; +}; + +inline JSC::WeakHandleOwner* wrapperOwner(DOMWrapperWorld&, CookieMap*) +{ + static NeverDestroyed owner; + return &owner.get(); +} + +inline void* wrapperKey(CookieMap* wrappableObject) +{ + return wrappableObject; +} + +JSC::JSValue toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, CookieMap&); +inline JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, CookieMap* impl) { return impl ? toJS(lexicalGlobalObject, globalObject, *impl) : JSC::jsNull(); } +JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject*, Ref&&); +inline JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, RefPtr&& impl) { return impl ? toJSNewlyCreated(lexicalGlobalObject, globalObject, impl.releaseNonNull()) : JSC::jsNull(); } + +template<> struct JSDOMWrapperConverterTraits { + using WrapperClass = JSCookieMap; + using ToWrappedReturnType = CookieMap*; +}; + +} // namespace WebCore diff --git a/src/bun.js/bindings/webcore/JSPerformanceResourceTiming.cpp b/src/bun.js/bindings/webcore/JSPerformanceResourceTiming.cpp index a77a899d7e..71f3027255 100644 --- a/src/bun.js/bindings/webcore/JSPerformanceResourceTiming.cpp +++ b/src/bun.js/bindings/webcore/JSPerformanceResourceTiming.cpp @@ -447,7 +447,7 @@ static inline EncodedJSValue jsPerformanceResourceTimingPrototypeFunction_toJSON auto* result = constructEmptyObject(lexicalGlobalObject); auto nameValue = toJS(*lexicalGlobalObject, throwScope, impl.name()); RETURN_IF_EXCEPTION(throwScope, {}); - result->putDirect(vm, Identifier::fromString(vm, "name"_s), nameValue); + result->putDirect(vm, vm.propertyNames->name, nameValue); auto entryTypeValue = toJS(*lexicalGlobalObject, throwScope, impl.entryType()); RETURN_IF_EXCEPTION(throwScope, {}); result->putDirect(vm, Identifier::fromString(vm, "entryType"_s), entryTypeValue); diff --git a/src/bun.js/bindings/webcore/JSPerformanceServerTiming.cpp b/src/bun.js/bindings/webcore/JSPerformanceServerTiming.cpp index 65c10c2b4f..70e5675976 100644 --- a/src/bun.js/bindings/webcore/JSPerformanceServerTiming.cpp +++ b/src/bun.js/bindings/webcore/JSPerformanceServerTiming.cpp @@ -220,7 +220,7 @@ static inline EncodedJSValue jsPerformanceServerTimingPrototypeFunction_toJSONBo auto* result = constructEmptyObject(lexicalGlobalObject); auto nameValue = toJS(*lexicalGlobalObject, throwScope, impl.name()); RETURN_IF_EXCEPTION(throwScope, {}); - result->putDirect(vm, Identifier::fromString(vm, "name"_s), nameValue); + result->putDirect(vm, vm.propertyNames->name, nameValue); auto durationValue = toJS(*lexicalGlobalObject, throwScope, impl.duration()); RETURN_IF_EXCEPTION(throwScope, {}); result->putDirect(vm, Identifier::fromString(vm, "duration"_s), durationValue); diff --git a/src/bun.js/webcore.zig b/src/bun.js/webcore.zig index 74910abfad..4c81c6350a 100644 --- a/src/bun.js/webcore.zig +++ b/src/bun.js/webcore.zig @@ -6,6 +6,7 @@ pub usingnamespace @import("./webcore/S3Stat.zig"); pub usingnamespace @import("./webcore/S3Client.zig"); pub usingnamespace @import("./webcore/request.zig"); pub usingnamespace @import("./webcore/body.zig"); +pub const CookieMap = @import("./webcore/CookieMap.zig").CookieMap; pub const ObjectURLRegistry = @import("./webcore/ObjectURLRegistry.zig"); const JSC = bun.JSC; const std = @import("std"); diff --git a/src/bun.js/webcore/CookieMap.zig b/src/bun.js/webcore/CookieMap.zig new file mode 100644 index 0000000000..24b7b62fe4 --- /dev/null +++ b/src/bun.js/webcore/CookieMap.zig @@ -0,0 +1,10 @@ +const bun = @import("root").bun; + +pub const CookieMap = opaque { + extern fn CookieMap__write(cookie_map: *CookieMap, global_this: *bun.JSC.JSGlobalObject, ssl_enabled: bool, uws_http_response: *anyopaque) void; + pub const write = CookieMap__write; + extern fn CookieMap__deref(cookie_map: *CookieMap) void; + pub const deref = CookieMap__deref; + extern fn CookieMap__ref(cookie_map: *CookieMap) void; + pub const ref = CookieMap__ref; +}; diff --git a/src/bun.js/webcore/request.zig b/src/bun.js/webcore/request.zig index 0119bc5d1f..f78d2dc414 100644 --- a/src/bun.js/webcore/request.zig +++ b/src/bun.js/webcore/request.zig @@ -81,6 +81,10 @@ pub const Request = struct { return @sizeOf(Request) + this.request_context.memoryCost() + this.url.byteSlice().len + this.body.value.memoryCost(); } + pub export fn Request__setCookiesOnRequestContext(this: *Request, cookieMap: ?*JSC.WebCore.CookieMap) void { + this.request_context.setCookies(cookieMap); + } + pub export fn Request__getUWSRequest( this: *Request, ) ?*uws.Request { diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index 6ecdaca12b..39f912a38b 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -98,6 +98,7 @@ using namespace JSC; macro(dirname) \ macro(disturbed) \ macro(document) \ + macro(domain) \ macro(encode) \ macro(encoding) \ macro(end) \ @@ -106,6 +107,7 @@ using namespace JSC; macro(evaluateCommonJSModule) \ macro(evaluated) \ macro(execArgv) \ + macro(expires) \ macro(exports) \ macro(extname) \ macro(failureKind) \ @@ -131,6 +133,7 @@ using namespace JSC; macro(host) \ macro(hostname) \ macro(href) \ + macro(httpOnly) \ macro(ignoreBOM) \ macro(importer) \ macro(inFlightCloseRequest) \ @@ -158,6 +161,7 @@ using namespace JSC; macro(makeDOMException) \ macro(makeErrorWithCode) \ macro(makeGetterTypeError) \ + macro(maxAge) \ macro(method) \ macro(mockedFunction) \ macro(mode) \ @@ -175,6 +179,7 @@ using namespace JSC; macro(overridableRequire) \ macro(ownerReadableStream) \ macro(parse) \ + macro(partitioned) \ macro(password) \ macro(patch) \ macro(path) \ @@ -218,6 +223,8 @@ using namespace JSC; macro(requireNativeModule) \ macro(resolveSync) \ macro(resume) \ + macro(sameSite) \ + macro(secure) \ macro(self) \ macro(sep) \ macro(setBody) \ diff --git a/test/js/bun/cookie/cookie-exotic-inputs.test.ts b/test/js/bun/cookie/cookie-exotic-inputs.test.ts new file mode 100644 index 0000000000..7c990d112a --- /dev/null +++ b/test/js/bun/cookie/cookie-exotic-inputs.test.ts @@ -0,0 +1,511 @@ +import { test, expect, describe } from "bun:test"; + +describe("Bun.Cookie.parse with exotic inputs", () => { + test("handles cookies with various special characters in name", () => { + // Test valid characters in cookie names per RFC + const validNameChars = [ + "name=value", + "n-a-m-e=value", // hyphen + "n.a.m.e=value", // dots + "n_a_m_e=value", // underscore + "_name=value", // starting with underscore + ".name=value", // starting with dot + "!#$%&'*+-.^_`|~=value", // all allowed special chars per RFC6265 + ]; + + for (const cookieStr of validNameChars) { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.value).toBe("value"); + } + }); + + test("handles unusual but valid values", () => { + const unusualValues = [ + "name=", // empty value + "name=;", // empty value with semicolon + "name=\t", // tab as value + "name= ", // space as value + "name=val ue", // space in value + "name=val+ue", // plus in value + "name=val%20ue", // url encoded space + 'name=val"ue', // quotes in value + "name=val<>ue", // angle brackets in value + "name=val()ue", // parentheses in value + "name=val[]ue", // brackets in value + "name=val{}ue", // braces in value + "name=val:ue", // colon in value + "name=val/ue", // slash in value + "name=val\\ue", // backslash in value + "name=val\tue", // tab in value + "name=val\nue", // newline in value (should be handled) + ]; + + for (const cookieStr of unusualValues) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + // Value might be truncated depending on the implementation + } catch (error) { + // Some implementations might reject certain values + expect(error).toBeDefined(); + } + } + }); + + test("handles strange but valid attribute formats", () => { + const strangeAttrs = [ + "name=value; Path=/", // normal + "name=value;Path=/", // no space after semicolon + "name=value; Path=/", // multiple spaces + "name=value;\tPath=/", // tab after semicolon + "name=value;\nPath=/", // newline after semicolon (should be handled) + "name=value;\rPath=/", // carriage return after semicolon + "name=value; \tPath=/", // space and tab before attribute + ]; + + for (const cookieStr of strangeAttrs) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.path).toBe("/"); + } catch (error) { + // Some implementations might be strict about format + expect(error).toBeDefined(); + } + } + }); + + test("handles unique case variations of attribute names", () => { + const caseVariants = [ + "name=value; Path=/", + "name=value; path=/", + "name=value; PATH=/", + "name=value; PaTh=/", + "name=value; Domain=example.com", + "name=value; domain=example.com", + "name=value; DOMAIN=example.com", + "name=value; DoMaIn=example.com", + "name=value; Secure", + "name=value; secure", + "name=value; SECURE", + "name=value; HttpOnly", + "name=value; httponly", + "name=value; HTTPONLY", + "name=value; SameSite=Strict", + "name=value; samesite=strict", + "name=value; SAMESITE=STRICT", + "name=value; Partitioned", + "name=value; partitioned", + "name=value; PARTITIONED", + ]; + + for (const cookieStr of caseVariants) { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + } + }); + + test("handles bizarre attribute value combinations", () => { + const bizarreAttrs = [ + // Empty attribute values + "name=value; Path=", + "name=value; Domain=", + // Quoted values + 'name=value; Path="/"', + 'name=value; Domain="example.com"', + // Spaces in attribute values + "name=value; Path= /", + "name=value; Path=/ ", + "name=value; Path= / ", + "name=value; Domain= example.com", + // Strange characters in attribute values + "name=value; Path=/weird#path?query=param", + "name=value; Domain=example.com:8080", + ]; + + for (const cookieStr of bizarreAttrs) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + } catch (error) { + // Some might be rejected, which is fine + expect(error).toBeDefined(); + } + } + }); + + describe("handles various Date formats for Expires", () => { + const dateFormats = [ + // Standard format + "name=value; Expires=Wed, 21 Oct 2025 07:28:00 GMT", + // Without day name + "name=value; Expires=21 Oct 2025 07:28:00 GMT", + // Different day format + "name=value; Expires=Wed, 21-Oct-2025 07:28:00 GMT", + // Without seconds + "name=value; Expires=Wed, 21 Oct 2025 07:28 GMT", + // Without time + "name=value; Expires=Wed, 21 Oct 2025", + // Without GMT + "name=value; Expires=Wed, 21 Oct 2025 07:28:00", + // With timezone offset + "name=value; Expires=Wed, 21 Oct 2025 07:28:00 +0000", + // Non-standard but often accepted + "name=value; Expires=2025-10-21T07:28:00Z", + ]; + + for (const cookieStr of dateFormats) { + test(cookieStr, () => { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + + // Expires should be set to some timestamp + if ("expires" in cookie) { + expect(typeof cookie.expires).toBe("number"); + } + } catch (error) { + // Some formats might not be supported + expect(error).toBeDefined(); + } + }); + } + }); + + test("handles boundary values for MaxAge", () => { + const maxAgeVariants = [ + "name=value; Max-Age=0", // Session cookie + "name=value; Max-Age=1", // 1 second + "name=value; Max-Age=2147483647", // Max 32-bit signed integer + "name=value; Max-Age=9007199254740991", // Number.MAX_SAFE_INTEGER + ]; + + for (const cookieStr of maxAgeVariants) { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + + const expectedMaxAge = parseInt(cookieStr.split("Max-Age=")[1]); + expect(cookie.maxAge).toBe(expectedMaxAge); + } + }); + + test("handles duplicate attribute values", () => { + const duplicateAttrs = [ + "name=value; Path=/foo; Path=/bar", + "name=value; Domain=example.com; Domain=other.com", + "name=value; SameSite=Strict; SameSite=Lax", + "name=value; Max-Age=100; Max-Age=200", + "name=value; Secure; Secure", + "name=value; HttpOnly; HttpOnly", + ]; + + for (const cookieStr of duplicateAttrs) { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + + // Usually the first value should win, but implementation may vary + } + }); + + test("handles mixed standard and non-standard attributes", () => { + const mixedAttrs = [ + "name=value; Path=/; Custom=something", + "name=value; Domain=example.com; SessionId=123456", + "name=value; SameSite=Strict; Priority=High", + "name=value; Max-Age=100; Version=1", + "name=value; Secure; CommentUrl=http://example.com/", + ]; + + for (const cookieStr of mixedAttrs) { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + + // Non-standard attributes should be ignored + } + }); + + test("handles exotic but RFC-compliant cookies", () => { + const exoticCompliantCases = [ + // Multiple cookie attributes of different types + "name=value; Path=/; Domain=example.com; Max-Age=3600; Secure; HttpOnly; SameSite=Strict; Partitioned", + // Strange but valid domain + "name=value; Domain=a.b-c.co.uk", + // Strange but valid path + "name=value; Path=/a/very/deep/path/with/many/segments", + // URL encoded chars in path + "name=value; Path=/path%20with%20spaces", + // URL encoded chars in domain (though questionable) + "name=value; Domain=weird%2Edomain.com", + ]; + + for (const cookieStr of exoticCompliantCases) { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + } + }); + + test("handles confusing value patterns", () => { + const confusingValues = [ + // Values that look like attributes + "name=Path=/; Domain=example.com", + "name=Secure; Path=/", + "name=Domain=example.com; Path=/", + "name=HttpOnly; Secure", + "name=SameSite=Lax; Path=/", + + // Values with semicolons that should be part of value + 'name="value; with; semicolons"; Path=/', + "name=value\\; still\\; value; Path=/", + + // Values with equals signs + "name=key=value; Path=/", + "name==; Path=/", + "name===; Path=/", + "name=a=b=c=d; Path=/", + + // Values with strange encoding + "name=%25%3B%3D%20; Path=/", // %25 = %, %3B = ;, %3D = =, %20 = space + "name=\\u003B\\u003D; Path=/", // JavaScript unicode escapes for ;= + ]; + + for (const cookieStr of confusingValues) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + // The value might be parsed differently depending on implementation + } catch (error) { + // Some implementations might reject these + expect(error).toBeDefined(); + } + } + }); + + test("handles exotic language characters", () => { + const languageVariants = [ + // Cookies with non-Latin characters + "name=こんにちは", // Japanese + "name=你好", // Chinese + "name=안녕하세요", // Korean + "name=Привет", // Russian + "name=مرحبا", // Arabic + "name=שלום", // Hebrew (right-to-left) + "name=Γειά σου", // Greek + "name=नमस्ते", // Hindi + + // With attributes + "name=こんにちは; Path=/jp", + "name=Γειά σου; Domain=example.gr; Path=/; Secure", + + // Non-Latin in attributes (which is not compliant but should be handled) + "name=value; Path=/こんにちは", + "name=value; Domain=例子.中国", // IDN domain + ]; + + for (const cookieStr of languageVariants) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + } catch (error) { + // Some implementations might reject non-ASCII + expect(error).toBeDefined(); + } + } + }); + + test("handles exceedingly complex combinations", () => { + const complexCases = [ + // Cookie with all possible standard attributes and extreme values + "name=value; Domain=very-long-domain-name-with-many-subdomains.example.co.uk; " + + "Path=/extremely/long/path/with/many/segments/that/goes/on/and/on; " + + "Expires=Wed, 21 Oct 2099 07:28:00 GMT; " + + "Max-Age=2147483647; " + + "Secure; HttpOnly; SameSite=Strict; Partitioned", + + // Cookie with unusual but valid name and value and all attributes + "!#$%&'*+-.^_`|~=v@lue_w!th-sp3c!@l_Ch@rs; " + + "Domain=example.com; Path=/; Expires=Wed, 21 Oct 2025 07:28:00 GMT; " + + "Max-Age=3600; Secure; HttpOnly; SameSite=None; Partitioned", + + // Cookie with mixed case in all attribute names + "name=value; dOmAiN=example.com; PaTh=/; ExPiReS=Wed, 21 Oct 2025 07:28:00 GMT; " + + "mAx-AgE=3600; SeCuRe; HtTpOnLy; SaMeSiTe=StRiCt; PaRtItIoNeD", + ]; + + for (const cookieStr of complexCases) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + } catch (error) { + // Some might be rejected for various reasons + expect(error).toBeDefined(); + } + } + }); + + test("handles whitespace variations", () => { + const whitespaceVariants = [ + // Normal spacing + "name=value; Path=/; Domain=example.com", + + // No spaces + "name=value;Path=/;Domain=example.com", + + // Excessive spaces + "name=value; Path=/; Domain=example.com", + + // Tabs instead of spaces + "name=value;\tPath=/;\tDomain=example.com", + + // Mixed whitespace + "name=value; \t Path=/;\r\n\tDomain=example.com", + + // Leading/trailing whitespace + " name=value; Path=/; Domain=example.com ", + + // Whitespace in name/value + "name =value; Path=/", + "name= value; Path=/", + "name = value; Path=/", + ]; + + for (const cookieStr of whitespaceVariants) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + // Name and value might be trimmed in some implementations + expect(cookie.name.trim()).toBe("name"); + expect(cookie.value.trim()).toBe("value"); + } catch (error) { + // Some might be rejected + expect(error).toBeDefined(); + } + } + }); + + test("handles cookies with control characters", () => { + const controlCharCases = [ + // Various ASCII control characters + "name=value\u0001more", // SOH + "name=value\u0002more", // STX + "name=value\u0003more", // ETX + "name=value\u0004more", // EOT + "name=value\u0005more", // ENQ + "name=value\u0006more", // ACK + "name=value\u0007more", // BEL + "name=value\bmore", // BS + "name=value\tmore", // HT (tab) + "name=value\nmore", // LF + "name=value\vmore", // VT + "name=value\fmore", // FF + "name=value\rmore", // CR + "name=value\u000Emore", // SO + "name=value\u000Fmore", // SI + "name=value\u0010more", // DLE + "name=value\u001Amore", // SUB + "name=value\u001Bmore", // ESC + "name=value\u001Cmore", // FS + "name=value\u001Dmore", // GS + "name=value\u001Emore", // RS + "name=value\u001Fmore", // US + "name=value\u007Fmore", // DEL + + // Control characters in attribute values + "name=value; Path=/\u0001path", + "name=value; Domain=example\u0002.com", + ]; + + for (const cookieStr of controlCharCases) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + // Control characters should ideally be stripped or rejected + } catch (error) { + // Rejecting is a valid response to control characters + expect(error).toBeDefined(); + } + } + }); + + test("handles cookies with emoji", () => { + const emojiCases = [ + // Simple emoji + "name=value🍪", + "name=🍪value", + "🍪=value", + + // Complex emoji (emoji with modifiers) + "name=value👨‍👩‍👧‍👦", + "name=value👩🏻‍💻", + + // Emoji in attributes + "name=value; Path=/🍪", + "name=value; Domain=example.🍪", // Invalid domain, but parser should handle + ]; + + for (const cookieStr of emojiCases) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + // Name might be rejected if it contains emoji + } catch (error) { + // Some implementations might reject emoji in names + expect(error).toBeDefined(); + } + } + }); + + test("handles unexpected format variations", () => { + const unexpectedFormats = [ + // Multiple equals signs in name-value pair + "name==value", + + // Random garbage after cookie value + "name=value garbage", + + // Multiple semicolons + "name=value;;; Path=/", + + // Semicolons with nothing after them + "name=value; ", + "name=value;", + + // Attributes with nothing after equals sign + "name=value; Path=; Domain=", + + // Just general weirdness + "name=value;;;;; Path====/;; Domain::::example.com", + ]; + + for (const cookieStr of unexpectedFormats) { + try { + const cookie = Bun.Cookie.parse(cookieStr); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + } catch (error) { + // Some might be rejected + expect(error).toBeDefined(); + } + } + }); +}); diff --git a/test/js/bun/cookie/cookie-expires-validation.test.ts b/test/js/bun/cookie/cookie-expires-validation.test.ts new file mode 100644 index 0000000000..8705f50519 --- /dev/null +++ b/test/js/bun/cookie/cookie-expires-validation.test.ts @@ -0,0 +1,162 @@ +import { test, expect, describe } from "bun:test"; + +describe("Bun.Cookie expires validation", () => { + describe("Date objects", () => { + test("accepts valid Date in future", () => { + const futureDate = new Date(Date.now() + 86400000); // 1 day in future + const cookie = new Bun.Cookie("name", "value", { expires: futureDate }); + expect(cookie.expires).toBeDefined(); + expect(typeof cookie.expires).toBe("object"); + // Check it's the expected timestamp in seconds + expect(cookie.expires).toEqual(futureDate); + }); + + test("accepts valid Date in past", () => { + const pastDate = new Date(Date.now() - 86400000); // 1 day in past + const cookie = new Bun.Cookie("name", "value", { expires: pastDate }); + expect(cookie.expires).toBeDefined(); + expect(typeof cookie.expires).toBe("object"); + // Check it's the expected timestamp in seconds + expect(cookie.expires).toEqual(pastDate); + }); + + test("throws for invalid Date (NaN)", () => { + const invalidDate = new Date("invalid date"); // Creates a Date with NaN value + expect(() => { + new Bun.Cookie("name", "value", { expires: invalidDate }); + }).toThrow("expires must be a valid Date (or Number)"); + }); + }); + + describe("Number values", () => { + test("accepts positive integer", () => { + const timestamp = Math.floor(Date.now() / 1000) + 86400; // 1 day in future (seconds) + const cookie = new Bun.Cookie("name", "value", { expires: timestamp }); + expect(cookie.expires).toEqual(new Date(timestamp * 1000)); + }); + + test("accepts zero", () => { + const cookie = new Bun.Cookie("name", "value", { expires: 0 }); + expect(cookie.expires).toEqual(new Date(0)); + }); + + test("throws for NaN", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: NaN }); + }).toThrow("expires must be a valid Number"); + }); + + test("throws for Infinity", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: Infinity }); + }).toThrow("expires must be a valid Number"); + }); + + test("throws for negative Infinity", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: -Infinity }); + }).toThrow("expires must be a valid Number"); + }); + }); + + describe("Special values", () => { + test("handles undefined", () => { + const cookie = new Bun.Cookie("name", "value", { expires: undefined }); + expect(cookie.expires).toBeUndefined(); + }); + + test("handles null", () => { + const cookie = new Bun.Cookie("name", "value", { expires: null }); + expect(cookie.expires).toBeUndefined(); + }); + + test("throws for non-date objects", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: { time: 123456 } }); + }).toThrow(); + }); + + test("invalid strings throw", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: "tomorrow" }); + }).toThrowErrorMatchingInlineSnapshot(`"Invalid cookie expiration date"`); + }); + + test("throws for arrays", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: [2023, 11, 25] }); + }).toThrowErrorMatchingInlineSnapshot(`"The argument 'expires' Invalid expires value. Must be a Date or a number. Received [ 2023, 11, 25 ]"`); + }); + + test("throws for booleans", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: true }); + }).toThrowErrorMatchingInlineSnapshot(`"The argument 'expires' Invalid expires value. Must be a Date or a number. Received true"`); + }); + }); + + describe("Constructors and methods", () => { + test("validates expires in cookie options object", () => { + expect(() => { + new Bun.Cookie({ + name: "test", + value: "value", + expires: NaN, + }); + }).toThrow("expires must be a valid Number"); + }); + + test("validates expires in Cookie.from", () => { + const invalidDate = new Date("invalid date"); + expect(() => { + Bun.Cookie.from("name", "value", { expires: invalidDate }); + }).toThrow("expires must be a valid Date (or Number)"); + }); + + test("handles valid expires in Cookie.from", () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; + const cookie = Bun.Cookie.from("name", "value", { expires: futureTimestamp }); + expect(cookie.expires).toEqual(new Date(futureTimestamp * 1000)); + }); + }); + + describe("Date arithmetic edge cases", () => { + test("handles Date at epoch", () => { + const epochDate = new Date(0); + const cookie = new Bun.Cookie("name", "value", { expires: epochDate }); + expect(cookie.expires).toEqual(epochDate); + }); + + test("handles Date at max timestamp", () => { + // Max date that can be represented + const maxDate = new Date(8640000000000000); + const cookie = new Bun.Cookie("name", "value", { expires: maxDate }); + expect(cookie.expires).toEqual(maxDate); + }); + + test("handles odd Date objects", () => { + // Date before epoch + const beforeEpoch = new Date(-1000); + // Should be converted to seconds but still positive because getTime() is still positive + const cookie = new Bun.Cookie("name", "value", { expires: beforeEpoch }); + expect(cookie.expires).toEqual(beforeEpoch); + }); + }); + + describe("Conversion edge cases", () => { + test("correctly divides milliseconds to seconds", () => { + // Create a date with a known millisecond timestamp + const date = new Date(1234567890123); + const cookie = new Bun.Cookie("name", "value", { expires: date }); + // Should be converted to seconds (÷ 1000) + expect(cookie.expires).toEqual(date); + }); + + test("handles fractional second timestamps", () => { + // Using a fractional timestamp in seconds + const timestamp = 1234567890.5; + const cookie = new Bun.Cookie("name", "value", { expires: timestamp }); + expect(cookie.expires).toEqual(new Date(timestamp * 1000)); + }); + }); +}); diff --git a/test/js/bun/cookie/cookie-map.test.ts b/test/js/bun/cookie/cookie-map.test.ts new file mode 100644 index 0000000000..5493e6aabd --- /dev/null +++ b/test/js/bun/cookie/cookie-map.test.ts @@ -0,0 +1,282 @@ +import { test, expect, describe } from "bun:test"; + +describe("Bun.Cookie and Bun.CookieMap", () => { + // Basic Cookie tests + test("can create a basic Cookie", () => { + const cookie = new Bun.Cookie("name", "value"); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.path).toBe("/"); + expect(cookie.domain).toBeNull(); + expect(cookie.secure).toBe(false); + expect(cookie.httpOnly).toBe(false); + expect(cookie.partitioned).toBe(false); + expect(cookie.sameSite).toBe("lax"); + }); + + test("can create a Cookie with options", () => { + const cookie = new Bun.Cookie("name", "value", { + domain: "example.com", + path: "/foo", + secure: true, + httpOnly: true, + partitioned: true, + sameSite: "lax", + maxAge: 3600, + }); + + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.domain).toBe("example.com"); + expect(cookie.path).toBe("/foo"); + expect(cookie.secure).toBe(true); + expect(cookie.httpOnly).toBe(true); + expect(cookie.partitioned).toBe(true); + expect(cookie.sameSite).toBe("lax"); + expect(cookie.maxAge).toBe(3600); + }); + + test("Cookie.toString() formats properly", () => { + const cookie = new Bun.Cookie("name", "value", { + domain: "example.com", + path: "/foo", + secure: true, + httpOnly: true, + partitioned: true, + sameSite: "strict", + maxAge: 3600, + }); + + const str = cookie.toString(); + expect(str).toInclude("name=value"); + expect(str).toInclude("Domain=example.com"); + expect(str).toInclude("Path=/foo"); + expect(str).toInclude("Max-Age=3600"); + expect(str).toInclude("Secure"); + expect(str).toInclude("HttpOnly"); + expect(str).toInclude("Partitioned"); + expect(str).toInclude("SameSite=Strict"); + expect(str).toMatchInlineSnapshot( + `"name=value; Domain=example.com; Path=/foo; Max-Age=3600; Secure; HttpOnly; Partitioned; SameSite=Strict"`, + ); + }); + + test("can set Cookie expires as Date", () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 1); // tomorrow + + const cookie = new Bun.Cookie("name", "value", { + expires: futureDate, + }); + + expect(cookie.isExpired()).toBe(false); + }); + + test("Cookie.isExpired() returns correct value", async () => { + // Expired cookie (max-age in the past) + const expiredCookie = new Bun.Cookie("name", "value", { + expires: new Date(Date.now() - 1000), + }); + expect(expiredCookie.isExpired()).toBe(true); + + // Non-expired cookie (future max-age) + const validCookie = new Bun.Cookie("name", "value", { + maxAge: 3600, // 1 hour + }); + expect(validCookie.isExpired()).toBe(false); + + // Session cookie (no expiration) + const sessionCookie = new Bun.Cookie("name", "value"); + expect(sessionCookie.isExpired()).toBe(false); + }); + + test("Cookie.parse works with all attributes", () => { + const cookieStr = + "name=value; Domain=example.com; Expires=Thu, 13 Mar 2025 12:00:00 GMT; Path=/foo; Max-Age=3600; Secure; HttpOnly; Partitioned; SameSite=Strict"; + const cookie = Bun.Cookie.parse(cookieStr); + + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.domain).toBe("example.com"); + expect(cookie.path).toBe("/foo"); + expect(cookie.maxAge).toBe(3600); + expect(cookie.secure).toBe(true); + expect(cookie.httpOnly).toBe(true); + expect(cookie.expires).toEqual(new Date("Thu, 13 Mar 2025 12:00:00 GMT")); + expect(cookie.partitioned).toBe(true); + expect(cookie.sameSite).toBe("strict"); + }); + + test("Cookie.serialize creates cookie string", () => { + const cookie1 = new Bun.Cookie("foo", "bar"); + const cookie2 = new Bun.Cookie("baz", "qux"); + + expect(cookie1.serialize() + "\n" + cookie2.serialize()).toMatchInlineSnapshot(` + "foo=bar; SameSite=Lax + baz=qux; SameSite=Lax" + `); + }); + + // Basic CookieMap tests + test("can create an empty CookieMap", () => { + const map = new Bun.CookieMap(); + expect(map.size).toBe(0); + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(`[]`); + }); + + test("can create CookieMap from string", () => { + const map = new Bun.CookieMap("name=value; foo=bar"); + expect(map.size).toBe(2); + + const cookie1 = map.get("name"); + expect(cookie1).toBeDefined(); + expect(cookie1).toBe("value"); + + const cookie2 = map.get("foo"); + expect(cookie2).toBeDefined(); + expect(cookie2).toBe("bar"); + + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(`[]`); + }); + + test("can create CookieMap from object", () => { + const map = new Bun.CookieMap({ + name: "value", + foo: "bar", + }); + + expect(map.size).toBe(2); + expect(map.get("name")).toBe("value"); + expect(map.get("foo")).toBe("bar"); + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(`[]`); + }); + + test("can create CookieMap from array pairs", () => { + const map = new Bun.CookieMap([ + ["name", "value"], + ["foo", "bar"], + ]); + + expect(map.size).toBe(2); + expect(map.get("name")).toBe("value"); + expect(map.get("foo")).toBe("bar"); + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(`[]`); + }); + + test("CookieMap methods work", () => { + const map = new Bun.CookieMap(); + + // Set a cookie with name/value + map.set("name", "value"); + expect(map.size).toBe(1); + expect(map.has("name")).toBe(true); + + // Set with cookie object + map.set( + new Bun.Cookie("foo", "bar", { + secure: true, + httpOnly: true, + partitioned: true, + }), + ); + expect(map.size).toBe(2); + expect(map.has("foo")).toBe(true); + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "name=value; SameSite=Lax", + "foo=bar; Secure; HttpOnly; Partitioned; SameSite=Lax", + ] + `); + + // Delete a cookie + map.delete("name"); + expect(map.size).toBe(1); + expect(map.has("name")).toBe(false); + expect(map.get("name")).toBe(null); + + // Get changes + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "foo=bar; Secure; HttpOnly; Partitioned; SameSite=Lax", + "name=; Expires=Fri, 1 Jan 1970 00:00:00 -0000; SameSite=Lax", + ] + `); + }); + + test("CookieMap supports iteration", () => { + const map = new Bun.CookieMap("a=1; b=2; c=3"); + + // Test keys() + const keys = Array.from(map.keys()); + expect(keys).toEqual(["a", "b", "c"]); + + // Test entries() + let count = 0; + for (const [key, value] of map.entries()) { + count++; + expect(typeof key).toBe("string"); + expect(typeof value).toBe("string"); + expect(["1", "2", "3"]).toContain(value); + } + expect(count).toBe(3); + + // Test forEach + const collected: string[] = []; + map.forEach((value, key) => { + collected.push(`${key}=${value}`); + }); + expect(collected.sort()).toEqual(["a=1", "b=2", "c=3"]); + }); + + test("CookieMap.toJSON() formats properly", () => { + const map = new Bun.CookieMap("a=1; b=2"); + expect(map.toJSON()).toMatchInlineSnapshot(` + { + "a": "1", + "b": "2", + } + `); + }); + + test("CookieMap works with cookies with advanced attributes", () => { + const map = new Bun.CookieMap(); + + // Add a cookie with httpOnly and partitioned flags + map.set("session", "abc123", { + httpOnly: true, + secure: true, + partitioned: true, + maxAge: 3600, + }); + + expect(map.get("session")).toBe("abc123"); + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "session=abc123; Max-Age=3600; Secure; HttpOnly; Partitioned; SameSite=Lax", + ] + `); + }); +}); + +describe("Cookie name field is immutable", () => { + test("can create a Cookie", () => { + const cookie = new Bun.Cookie("name", "value"); + expect(cookie.name).toBe("name"); + // @ts-expect-error + cookie.name = "foo"; + expect(cookie.name).toBe("name"); + }); + test("mutate cookie in map", () => { + const cookieMap = new Bun.CookieMap(); + const cookie = new Bun.Cookie("name", "value"); + cookieMap.set(cookie); + expect(cookieMap.get("name")).toBe("value"); + cookie.value = "value2"; + expect(cookieMap.get("name")).toBe("value2"); + expect(cookieMap.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "name=value2; SameSite=Lax", + ] + `); + }); +}); diff --git a/test/js/bun/cookie/cookie-security-fuzz.test.ts b/test/js/bun/cookie/cookie-security-fuzz.test.ts new file mode 100644 index 0000000000..a20c6ba6d1 --- /dev/null +++ b/test/js/bun/cookie/cookie-security-fuzz.test.ts @@ -0,0 +1,325 @@ +import { test, expect, describe } from "bun:test"; + +describe("Bun.Cookie.parse security fuzz tests", () => { + // Security-focused fuzz tests + describe("resists cookie format injection attacks", () => { + // Attempt to inject additional cookies via name or value + const injectionCases = [ + "name=value\nSet-Cookie: inject=bad", + "name=value\r\nSet-Cookie: inject=bad", + "name=value\n\rSet-Cookie: inject=bad", + "name=value\r\n\r\nSet-Cookie: inject=bad", + "name=value\u0000Set-Cookie: inject=bad", + "name=value\u2028Set-Cookie: inject=bad", // Line separator + "name=value\u2029Set-Cookie: inject=bad", // Paragraph separator + "name\r\nSet-Cookie: inject=bad;=value", + "name\nSet-Cookie: inject=bad;=value", + ]; + + for (const injectionCase of injectionCases) { + test(injectionCase, () => { + expect(() => Bun.Cookie.parse(injectionCase)).toThrow(); + }); + } + + // Additional cookies are simply ignored + test("additional cookies are simply ignored", () => { + const cookie = Bun.Cookie.parse("name=value; Set-Cookie: inject=bad; other=value"); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + }); + }); + + describe("handles header splitting attacks", () => { + const headerSplittingCases = [ + "name=value\r\nBadHeader: injection", + "name=value\nBadHeader: injection", + "name=value\r\n\r\nBadHeader: injection", + "name=value\n\nBadHeader: injection", + "name=value\rBadHeader: injection", + ]; + + for (const headerSplittingCase of headerSplittingCases) { + test(headerSplittingCase, () => { + expect(() => Bun.Cookie.parse(headerSplittingCase)).toThrow(); + }); + } + }); + + describe("handles non-ASCII characters in cookie values", () => { + const nonAsciiCases = [ + "name=值", // Chinese + "name=Значение", // Russian + "name=قيمة", // Arabic + "name=γιά σου", // Greek + "name=😊🍪", // Emoji + "name=\u2603", // Snowman + "name=\u{1F4A9}", // Pile of poo emoji (surrogate pair) + ]; + + for (const nonAsciiCase of nonAsciiCases) { + test(nonAsciiCase, () => { + expect(() => Bun.Cookie.parse(nonAsciiCase)).toThrow(); + }); + } + }); + + test("resists RegExp denial of service attacks", () => { + // Potential ReDoS patterns + const redosPatterns = [ + `name=value; Path=${"a".repeat(1000)}${"b?".repeat(1000)}`, + `name=${"a".repeat(1000)}${"b+".repeat(1000)}`, + `name=value; Domain=${"a".repeat(500)}${".*".repeat(500)}`, + ]; + + for (const redosPattern of redosPatterns) { + try { + // Should parse in reasonable time or throw + const startTime = performance.now(); + const cookie = Bun.Cookie.parse(redosPattern); + const parseTime = performance.now() - startTime; + + // Shouldn't take an unreasonable amount of time (adjust threshold as needed) + expect(parseTime).toBeLessThan(1000); // 1 second max + } catch (error) { + // Throwing is acceptable if it can't handle the input + expect(error).toBeDefined(); + } + } + }); + + test("handles attribute value injection attempts", () => { + const attrInjectionCases = [ + "name=value; Path=/; Domain=evil.com", + "name=value; Path=/; Domain=evil.com; Secure=false", + "name=value; Secure=false", // Trying to override boolean attribute + "name=value; HttpOnly=0", // Trying to override boolean attribute + "name=value; SameSite=Strict; SameSite=None", // Duplicate attributes + "name=value; Path=/; Path=/admin", // Duplicate attributes + ]; + + for (const attrInjectionCase of attrInjectionCases) { + const cookie = Bun.Cookie.parse(attrInjectionCase); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + + // Boolean attributes should be boolean + if ("secure" in cookie) { + expect(typeof cookie.secure).toBe("boolean"); + } + if ("httpOnly" in cookie) { + expect(typeof cookie.httpOnly).toBe("boolean"); + } + + // SameSite should be one of the expected values + if ("sameSite" in cookie) { + expect(["strict", "lax", "none"]).toContain(cookie.sameSite); + } + } + }); + + test("handles attribute-like patterns in values", () => { + const attrLikeValueCases = [ + "name=value; not an attribute", + "name=value with; semicolons", + "name=value; with Path=/like tokens", + "name=value; Domain", + "name=value; =strangeness", + "name=Path=/; value", + ]; + + for (const attrLikeCase of attrLikeValueCases) { + try { + const cookie = Bun.Cookie.parse(attrLikeCase); + expect(cookie.name).toBe("name"); + // Value might be truncated at semicolon depending on implementation + } catch (error) { + // Some implementations might reject these + expect(error).toBeDefined(); + } + } + }); + + describe("handles various tricky and edge case patterns", () => { + const trickyCases = [ + // Escaped quotes in values + 'name=value\\"with\\"quotes', + // Mixed upper/lowercase + "nAmE=VaLuE; pAtH=/; dOmAiN=example.com", + // Just barely valid + "n=v", + // Multiple equals in value (only first = should be used) + "name=value=more=equals", + // Control characters + "name=value\u0001\u0002\u0003", + // Backslashes + "name=value\\\\; Path=\\/", + // Very unusual cookie name (but valid) + "!#$%&'*+-.^_`|~=value", + ]; + + for (const trickyCase of trickyCases) { + test(trickyCase, () => { + Bun.Cookie.parse(trickyCase); + }); + } + + const throwCases = [ + // Unicode in attribute names (should be rejected or handled safely) + "name=value; 🍪=bad", + ]; + + for (const throwCase of throwCases) { + test(throwCase, () => { + expect(() => Bun.Cookie.parse(throwCase)).toThrow(); + }); + } + }); + + test("handles malicious MaxAge and Expires combinations", () => { + const maliciousCases = [ + // Conflicting directives + "name=value; Max-Age=0; Expires=Wed, 21 Oct 2025 07:28:00 GMT", + "name=value; Max-Age=3600; Expires=Wed, 21 Oct 2015 07:28:00 GMT", // Past date + // Extremely large values + "name=value; Max-Age=9999999999999", + "name=value; Expires=Wed, 21 Oct 9999 07:28:00 GMT", + // Negative values + "name=value; Max-Age=-1", + // Overflow attempts + "name=value; Max-Age=" + Number.MAX_SAFE_INTEGER, + "name=value; Max-Age=" + (Number.MAX_SAFE_INTEGER + 1), + ]; + + for (const maliciousCase of maliciousCases) { + try { + const cookie = Bun.Cookie.parse(maliciousCase); + expect(cookie).toBeDefined(); + if (cookie.maxAge !== undefined) { + // Max-Age should be a reasonable number, not NaN or Infinity + expect(Number.isFinite(cookie.maxAge)).toBe(true); + } + if (cookie.expires !== undefined) { + // Expires should be a reasonable timestamp, not NaN + expect(Number.isFinite(cookie.expires)).toBe(true); + } + } catch (error) { + // Some cases might be rejected, which is fine + expect(error).toBeDefined(); + } + } + }); + + test("handles SQL injection attempts in cookie values", () => { + const sqlInjectionCases = [ + "name=value' OR '1'='1", + "name=value'; DROP TABLE users; --", + "name=value' UNION SELECT * FROM passwords; --", + 'name=value"); DROP TABLE users; --', + "name=value' OR '1'='1'; Path=/admin", + ]; + + for (const sqlInjectionCase of sqlInjectionCases) { + const cookie = Bun.Cookie.parse(sqlInjectionCase); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + // The value should include the SQL injection as-is, since it's just text to the cookie parser + const expectedValue = sqlInjectionCase.substring(5).split(";")[0]; + expect(cookie.value).toBe(expectedValue); + } + }); + + test("handles potential prototype pollution attacks", () => { + const prototypePollutionCases = [ + "name=value; __proto__=polluted", + "name=value; constructor=polluted", + "name=value; prototype=polluted", + "__proto__=value; name=test", + "constructor=value; name=test", + "prototype=value; name=test", + ]; + + for (const pollutionCase of prototypePollutionCases) { + const cookie = Bun.Cookie.parse(pollutionCase); + expect(cookie).toBeDefined(); + + // These standard methods and properties should still be intact and not polluted + expect(typeof Object.prototype.toString).toBe("function"); + expect({}.constructor).toBe(Object); + expect(JSON.parse(JSON.stringify(cookie.toJSON()))).toStrictEqual(JSON.parse(JSON.stringify(cookie))); + } + }); + + test("handles null byte injection attempts", () => { + const nullByteAttacks = [ + "name=value\u0000malicious", + "name\u0000malicious=value", + "name=value; Path=/\u0000malicious", + "name=value; Domain=example.com\u0000malicious", + "name=value; SameSite=Strict\u0000None", + ]; + + for (const nullByteAttack of nullByteAttacks) { + try { + const cookie = Bun.Cookie.parse(nullByteAttack); + expect(cookie).toBeDefined(); + + // Ensure null bytes aren't present in the parsed values + if (cookie.name) { + expect(cookie.name).not.toInclude("\u0000"); + } + if (cookie.value) { + expect(cookie.value).not.toInclude("\u0000"); + } + if (cookie.path) { + expect(cookie.path).not.toInclude("\u0000"); + } + if (cookie.domain) { + expect(cookie.domain).not.toInclude("\u0000"); + } + } catch (error) { + // It's fine to reject strings with null bytes + expect(error).toBeDefined(); + } + } + }); + + describe("handles invalid Partitioned attribute uses", () => { + const partitionedCases = [ + "name=value; Partitioned", + "name=value; Partitioned=true", + "name=value; Partitioned=false", // Trying to set it to false + "name=value; Partitioned=1", + "name=value; Partitioned=0", + "name=value; Partitioned; Partitioned=false", // Duplicate with conflict + ]; + + for (const partitionedCase of partitionedCases) { + test(partitionedCase, () => { + const cookie = Bun.Cookie.parse(partitionedCase); + expect(cookie).toBeDefined(); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + + // Partitioned is always true if present. + expect(cookie.partitioned).toBe(true); + }); + } + }); + + describe("handles unicode homograph attacks", () => { + // These are characters that look similar to ASCII but are different + const homographCases = [ + "nаme=value", // Cyrillic 'а' (U+0430) instead of Latin 'a' + "name=vаlue", // Cyrillic 'а' (U+0430) in value + "name=value; Pаth=/", // Cyrillic 'а' (U+0430) in attribute name + "name=value; Domаin=example.com", // Cyrillic 'а' (U+0430) in attribute name + ]; + + for (const homographCase of homographCases) { + test(homographCase, () => { + expect(() => new Bun.Cookie(homographCase)).toThrowError(); + }); + } + }); +}); diff --git a/test/js/bun/cookie/cookie.test.ts b/test/js/bun/cookie/cookie.test.ts new file mode 100644 index 0000000000..c391284895 --- /dev/null +++ b/test/js/bun/cookie/cookie.test.ts @@ -0,0 +1,248 @@ +import { test, expect, describe } from "bun:test"; + +describe("Bun.Cookie validation tests", () => { + describe("expires validation", () => { + test("accepts valid Date for expires", () => { + const futureDate = new Date(Date.now() + 86400000); // 1 day in the future + const cookie = new Bun.Cookie("name", "value", { expires: futureDate }); + expect(cookie.expires).toBeDefined(); + expect(cookie.expires).toBeDate(); + expect(cookie.expires).toEqual(futureDate); + }); + + test("accepts valid number for expires", () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; // 1 day in the future (in seconds) + const cookie = new Bun.Cookie("name", "value", { expires: futureTimestamp }); + expect(cookie.expires).toEqual(new Date(futureTimestamp * 1000)); + }); + + test("throws for NaN Date", () => { + const invalidDate = new Date("invalid date"); // Creates a Date with NaN value + expect(() => { + new Bun.Cookie("name", "value", { expires: invalidDate }); + }).toThrow("expires must be a valid Date (or Number)"); + }); + + test("throws for NaN number", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: NaN }); + }).toThrow("expires must be a valid Number"); + }); + + test("throws for non-finite number (Infinity)", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: Infinity }); + }).toThrow("expires must be a valid Number"); + }); + + test("does not throw for negative number", () => { + expect(() => { + new Bun.Cookie("name", "value", { expires: -1 }); + }).not.toThrow(); + + expect(new Bun.Cookie("name", "value", { expires: -1 }).expires).toEqual(new Date(-1 * 1000)); + }); + + test("handles undefined expires correctly", () => { + const cookie = new Bun.Cookie("name", "value", { expires: undefined }); + expect(cookie.expires).toBeUndefined(); + }); + + test("handles null expires correctly", () => { + // @ts-expect-error + const cookie = new Bun.Cookie("name", "value", { expires: null }); + expect(cookie.expires).toBeUndefined(); + }); + }); + + describe("Cookie.from validation", () => { + test("throws for NaN Date in Cookie.from", () => { + const invalidDate = new Date("invalid date"); + expect(() => { + Bun.Cookie.from("name", "value", { expires: invalidDate }); + }).toThrow("expires must be a valid Date (or Number)"); + }); + + test("throws for NaN number in Cookie.from", () => { + expect(() => { + Bun.Cookie.from("name", "value", { expires: NaN }); + }).toThrow("expires must be a valid Number"); + }); + + test("throws for non-finite number in Cookie.from", () => { + expect(() => { + Bun.Cookie.from("name", "value", { expires: Infinity }); + }).toThrow("expires must be a valid Number"); + }); + }); + + describe("CookieInit validation", () => { + test("throws with invalid expires when creating with options object", () => { + expect(() => { + new Bun.Cookie({ + name: "test", + value: "value", + expires: NaN, + }); + }).toThrow("expires must be a valid Number"); + }); + + test("accepts valid expires when creating with options object", () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; + const cookie = new Bun.Cookie({ + name: "test", + value: "value", + expires: futureTimestamp, + }); + expect(cookie.expires).toEqual(new Date(futureTimestamp * 1000)); + }); + }); +}); + +describe("Bun.serve() cookies", () => { + const server = Bun.serve({ + port: 0, + routes: { + "/tester": { + POST: async req => { + const body: [string, string | null, { domain?: string; path?: string } | undefined][] = await req.json(); + for (const [key, value, options] of body) { + if (value == null) { + req.cookies.delete({ + name: key, + ...options, + }); + } else { + req.cookies.set(key, value, options); + } + } + return new Response(JSON.stringify(req.cookies), { + headers: { + "Content-Type": "application/json", + }, + }); + }, + }, + }, + }); + + test("set-cookie", async () => { + const res = await fetch(server.url + "/tester", { + method: "POST", + body: JSON.stringify([["test", "test"]]), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchInlineSnapshot(` + { + "test": "test", + } + `); + expect(res.headers.getAll("Set-Cookie")).toMatchInlineSnapshot(` + [ + "test=test; SameSite=Lax", + ] + `); + }); + test("set two cookies", async () => { + const res = await fetch(server.url + "/tester", { + method: "POST", + body: JSON.stringify([ + ["test", "test"], + ["test2", "test2"], + ]), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchInlineSnapshot(` + { + "test": "test", + "test2": "test2", + } + `); + expect(res.headers.getAll("Set-Cookie")).toMatchInlineSnapshot(` + [ + "test=test; SameSite=Lax", + "test2=test2; SameSite=Lax", + ] + `); + }); + test("delete cookie", async () => { + const res = await fetch(server.url + "/tester", { + method: "POST", + body: JSON.stringify([["test", null]]), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchInlineSnapshot(`{}`); + expect(res.headers.getAll("Set-Cookie")).toMatchInlineSnapshot(` + [ + "test=; Expires=Fri, 1 Jan 1970 00:00:00 -0000; SameSite=Lax", + ] + `); + }); + test("request with cookies", async () => { + const res = await fetch(server.url + "/tester", { + method: "POST", + body: JSON.stringify([ + ["do_modify", "c"], + ["add_cookie", "d"], + ]), + headers: { + "Cookie": "dont_modify=a;do_modify=b", + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchInlineSnapshot(` + { + "add_cookie": "d", + "do_modify": "c", + "dont_modify": "a", + } + `); + expect(res.headers.getAll("Set-Cookie")).toMatchInlineSnapshot(` + [ + "do_modify=c; SameSite=Lax", + "add_cookie=d; SameSite=Lax", + ] + `); + }); + test("request that doesn't modify cookies doesn't set cookies", async () => { + const res = await fetch(server.url + "/tester", { + method: "POST", + body: JSON.stringify([]), + headers: { + "Cookie": "dont_modify=a;another_cookie=b", + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchInlineSnapshot(` + { + "another_cookie": "b", + "dont_modify": "a", + } + `); + expect(res.headers.getAll("Set-Cookie")).toMatchInlineSnapshot(`[]`); + expect(res.headers.get("Set-Cookie")).toBeNull(); + }); + test("getAllChanges", () => { + const map = new Bun.CookieMap("dont_modify=ONE; do_modify=TWO; do_delete=THREE"); + map.set("do_modify", "FOUR"); + map.delete("do_delete"); + map.set("do_modify", "FIVE"); + expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "do_delete=; Expires=Fri, 1 Jan 1970 00:00:00 -0000; SameSite=Lax", + "do_modify=FIVE; SameSite=Lax", + ] + `); + expect(map.toJSON()).toMatchInlineSnapshot(` + { + "do_modify": "FIVE", + "dont_modify": "ONE", + } + `); + }); +}); diff --git a/test/js/bun/http/bun-serve-cookies.test.ts b/test/js/bun/http/bun-serve-cookies.test.ts new file mode 100644 index 0000000000..e27d172199 --- /dev/null +++ b/test/js/bun/http/bun-serve-cookies.test.ts @@ -0,0 +1,595 @@ +import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; +import { isBroken, isMacOS } from "harness"; +import type { Server, ServeOptions, BunRequest } from "bun"; + +describe("request cookies", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + routes: { + "/before-headers": req => { + // Access cookies before accessing headers + const cookies = req.cookies; + expect(cookies).toBeDefined(); + expect(cookies.size).toBe(2); + expect(cookies.get("name")).toBe("value"); + expect(cookies.get("foo")).toBe("bar"); + + // Verify headers are still accessible afterward + expect(req.headers.get("cookie")).toBe("name=value; foo=bar"); + + return new Response("ok"); + }, + "/after-headers": req => { + // Access headers first + const cookieHeader = req.headers.get("cookie"); + expect(cookieHeader).toBe("name=value; foo=bar"); + + // Then access cookies + const cookies = req.cookies; + expect(cookies).toBeDefined(); + expect(cookies.size).toBe(2); + expect(cookies.get("name")).toBe("value"); + expect(cookies.get("foo")).toBe("bar"); + + return new Response("ok"); + }, + "/no-cookies": req => { + // Test with no cookies in request + const cookies = req.cookies; + expect(cookies).toBeDefined(); + expect(cookies.size).toBe(0); + + return new Response("ok"); + }, + "/cookies-readonly": req => { + // Verify cookies property is readonly + try { + // @ts-expect-error - This should fail at runtime + req.cookies = {}; + return new Response("not ok - should have thrown"); + } catch (e) { + return new Response("ok - readonly"); + } + }, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("parses cookies before headers are accessed", async () => { + const res = await fetch(`${server.url}before-headers`, { + headers: { + "Cookie": "name=value; foo=bar", + }, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("parses cookies after headers are accessed", async () => { + const res = await fetch(`${server.url}after-headers`, { + headers: { + "Cookie": "name=value; foo=bar", + }, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("handles requests with no cookies", async () => { + const res = await fetch(`${server.url}no-cookies`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("has readonly cookies property", async () => { + const res = await fetch(`${server.url}cookies-readonly`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok - readonly"); + }); +}); + +describe("instanceof and type checks", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + routes: { + "/instanceof-checks": req => { + // Check that cookies is an instance of Bun.CookieMap + expect(req.cookies instanceof Bun.CookieMap).toBe(true); + + const cookie = req.cookies.get("name"); + expect(cookie).toBeTypeOf("string"); + + return new Response("ok"); + }, + "/constructor-identities": req => { + // Verify that the constructors match + expect(req.cookies.constructor).toBe(Bun.CookieMap); + + const cookie = req.cookies.get("name"); + expect(cookie).toBeTypeOf("string"); + + return new Response("ok"); + }, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("cookies is instance of Bun.CookieMap and has right prototype", async () => { + const res = await fetch(`${server.url}instanceof-checks`, { + headers: { + "Cookie": "name=value", + }, + }); + expect(res.status).toBe(200); + }); + + it("constructors match expected types", async () => { + const res = await fetch(`${server.url}constructor-identities`, { + headers: { + "Cookie": "name=value", + }, + }); + expect(res.status).toBe(200); + }); +}); + +describe("complex cookie parsing", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + routes: { + "/special-chars": req => { + const cookie = req.cookies.get("complex"); + if (cookie == null) { + return new Response("no cookie found", { status: 500 }); + } + + expect(cookie).toBe("value with spaces"); + return new Response("ok"); + }, + "/equals-in-value": req => { + const cookie = req.cookies.get("equation"); + if (cookie == null) { + return new Response("no cookie found", { status: 500 }); + } + + expect(cookie).toBe("x=y+z"); + return new Response("ok"); + }, + "/multiple-cookies": req => { + // Cookie with same name multiple times should be parsed correctly + const cookies = req.cookies; + expect(cookies.size).toBeGreaterThanOrEqual(2); + + // Get first occurrence of duplicate cookie + const duplicateCookie = cookies.get("duplicate"); + expect(duplicateCookie).toBeDefined(); + + // In most implementations, the first value should be preserved + expect(duplicateCookie).toBe("first"); + + return new Response("ok"); + }, + "/cookie-map-methods": req => { + const cookies = req.cookies; + + // Test has() method + expect(cookies.has("name")).toBe(true); + expect(cookies.has("nonexistent")).toBe(false); + + // Test size + expect(cookies.size).toBe(2); + + return new Response("ok"); + }, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("handles cookie values with spaces", async () => { + const res = await fetch(`${server.url}special-chars`, { + headers: { + "Cookie": "complex=value with spaces", + }, + }); + expect(res.status).toBe(200); + }); + + it("handles cookie values with equals signs", async () => { + const res = await fetch(`${server.url}equals-in-value`, { + headers: { + "Cookie": "equation=x=y+z", + }, + }); + expect(res.status).toBe(200); + }); + + it("handles duplicate cookie names", async () => { + const res = await fetch(`${server.url}multiple-cookies`, { + headers: { + "Cookie": "duplicate=first; duplicate=second; other=value", + }, + }); + expect(res.status).toBe(200); + }); + + it("CookieMap methods work correctly", async () => { + const res = await fetch(`${server.url}cookie-map-methods`, { + headers: { + "Cookie": "name=value; foo=bar", + }, + }); + expect(res.status).toBe(200); + }); +}); + +describe("CookieMap iterator", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + routes: { + "/iterator-entries": req => { + const cookies = req.cookies; + + // Test entries() iterator + const entries = Array.from(cookies.entries()); + expect(entries.length).toBe(3); + + // Entries should be [name, Cookie] pairs + expect(entries[0][0]).toBeTypeOf("string"); + expect(entries[0][1]).toBeTypeOf("string"); + + // Check that we can get cookies values + const cookieNames = entries.map(([name, _]) => name); + expect(cookieNames).toContain("a"); + expect(cookieNames).toContain("b"); + expect(cookieNames).toContain("c"); + + const cookieValues = entries.map(([_, value]) => value); + expect(cookieValues).toContain("1"); + expect(cookieValues).toContain("2"); + expect(cookieValues).toContain("3"); + + return new Response("ok"); + }, + "/iterator-for-of": req => { + const cookies = req.cookies; + + // Test for...of iteration (should iterate over entries) + const collected: { name: string; value: string }[] = []; + for (const entry of cookies) { + // Check that we get [name, cookie] entries + expect(entry.length).toBe(2); + expect(entry[0]).toBeTypeOf("string"); + expect(entry[1]).toBeTypeOf("string"); + + const [name, value] = entry; + collected.push({ name, value }); + } + + expect(collected.length).toBe(3); + expect(collected.some(c => c.name === "a" && c.value === "1")).toBe(true); + expect(collected.some(c => c.name === "b" && c.value === "2")).toBe(true); + expect(collected.some(c => c.name === "c" && c.value === "3")).toBe(true); + + return new Response("ok"); + }, + "/iterator-keys-values": req => { + const cookies = req.cookies; + + // Test keys() iterator + const keys = Array.from(cookies.keys()); + expect(keys.length).toBe(3); + expect(keys).toContain("a"); + expect(keys).toContain("b"); + expect(keys).toContain("c"); + + // Test values() iterator - returns Cookie objects + const values = Array.from(cookies.values()); + expect(values.length).toBe(3); + + // Values should be Cookie objects + for (const value of values) { + expect(value).toBeTypeOf("string"); + } + + // Values should include the expected cookies + const cookieValues = values; + expect(cookieValues).toContain("1"); + expect(cookieValues).toContain("2"); + expect(cookieValues).toContain("3"); + + return new Response("ok"); + }, + "/iterator-forEach": req => { + const cookies = req.cookies; + + // Test forEach method + const collected: { key: string; value: string }[] = []; + cookies.forEach((value, key) => { + expect(value).toBeTypeOf("string"); + expect(key).toBeTypeOf("string"); + collected.push({ key, value }); + }); + + expect(collected.length).toBe(3); + expect(collected.some(c => c.key === "a" && c.value === "1")).toBe(true); + expect(collected.some(c => c.key === "b" && c.value === "2")).toBe(true); + expect(collected.some(c => c.key === "c" && c.value === "3")).toBe(true); + + return new Response("ok"); + }, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("implements entries() iterator", async () => { + const res = await fetch(`${server.url}iterator-entries`, { + headers: { + "Cookie": "a=1; b=2; c=3", + }, + }); + expect(res.status).toBe(200); + }); + + it("implements for...of iteration", async () => { + const res = await fetch(`${server.url}iterator-for-of`, { + headers: { + "Cookie": "a=1; b=2; c=3", + }, + }); + expect(res.status).toBe(200); + }); + + it("implements keys() and values() iterators", async () => { + const res = await fetch(`${server.url}iterator-keys-values`, { + headers: { + "Cookie": "a=1; b=2; c=3", + }, + }); + expect(res.status).toBe(200); + }); + + it("implements forEach method", async () => { + const res = await fetch(`${server.url}iterator-forEach`, { + headers: { + "Cookie": "a=1; b=2; c=3", + }, + }); + expect(res.status).toBe(200); + }); +}); + +describe("Direct usage of Bun.Cookie and Bun.CookieMap", () => { + it("can create a Cookie directly", () => { + const cookie = new Bun.Cookie("name", "value"); + + expect(cookie.constructor).toBe(Bun.Cookie); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.path).toBe("/"); + // Domain may be null in the implementation + expect(cookie.domain == null || cookie.domain === "").toBe(true); + expect(cookie.secure).toBe(false); + expect(cookie.sameSite).toBe("lax"); + }); + + it("can create a Cookie with options", () => { + const cookie = new Bun.Cookie("name", "value", { + path: "/path", + domain: "example.com", + secure: true, + sameSite: "lax", + }); + + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.path).toBe("/path"); + expect(cookie.domain).toBe("example.com"); + expect(cookie.secure).toBe(true); + expect(cookie.sameSite).toBe("lax"); + }); + + it("can create a CookieMap directly", () => { + const cookieMap = new Bun.CookieMap(); + + expect(cookieMap.constructor).toBe(Bun.CookieMap); + expect(cookieMap.size).toBe(0); + }); + + it("can create a CookieMap with a cookie string", () => { + const cookieMap = new Bun.CookieMap("name=value; foo=bar"); + + expect(cookieMap.size).toBe(2); + + const nameCookie = cookieMap.get("name"); + expect(nameCookie).toMatchInlineSnapshot(`"value"`); + + const fooCookie = cookieMap.get("foo"); + expect(fooCookie).toMatchInlineSnapshot(`"bar"`); + + expect(cookieMap.toSetCookieHeaders()).toMatchInlineSnapshot(`[]`); + }); + + it("can create a CookieMap with an object", () => { + const cookieMap = new Bun.CookieMap({ + name: "value", + foo: "bar", + }); + + expect(cookieMap.size).toBe(2); + + const nameCookie = cookieMap.get("name"); + expect(nameCookie).toMatchInlineSnapshot(`"value"`); + + const fooCookie = cookieMap.get("foo"); + expect(fooCookie).toMatchInlineSnapshot(`"bar"`); + }); + + it("can create a CookieMap with an array of pairs", () => { + const cookieMap = new Bun.CookieMap([ + ["name", "value"], + ["foo", "bar"], + ]); + + expect(cookieMap.size).toBe(2); + + const nameCookie = cookieMap.get("name"); + expect(nameCookie).toMatchInlineSnapshot(`"value"`); + + const fooCookie = cookieMap.get("foo"); + expect(fooCookie).toMatchInlineSnapshot(`"bar"`); + }); + + it("can set and get cookies in a CookieMap", () => { + const cookieMap = new Bun.CookieMap(); + + // Set with name/value + cookieMap.set("name", "value"); + + // Set with options + cookieMap.set({ + name: "foo", + value: "bar", + secure: true, + path: "/path", + }); + + expect(cookieMap.size).toBe(2); + + const nameCookie = cookieMap.get("name"); + console.log(nameCookie); + expect(nameCookie).toMatchInlineSnapshot(`"value"`); + + const fooCookie = cookieMap.get("foo"); + expect(fooCookie).toMatchInlineSnapshot(`"bar"`); + expect(cookieMap.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "name=value; SameSite=Lax", + "foo=bar; Path=/path; Secure; SameSite=Lax", + ] + `); + }); + + it("can use Cookie.parse to parse cookie strings", () => { + const cookie = Bun.Cookie.parse("name=value; Path=/; Secure; SameSite=Lax"); + + expect(cookie.constructor).toBe(Bun.Cookie); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.path).toBe("/"); + expect(cookie.secure).toBe(true); + expect(cookie.sameSite.toLowerCase()).toBe("lax"); + }); + + it("can use Cookie.from to create cookies", () => { + const cookie = Bun.Cookie.from("name", "value", { + path: "/path", + domain: "example.com", + secure: true, + sameSite: "none", + }); + + expect(cookie.constructor).toBe(Bun.Cookie); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.path).toBe("/path"); + expect(cookie.domain).toBe("example.com"); + expect(cookie.secure).toBe(true); + expect(cookie.sameSite.toLowerCase()).toBe("none"); + }); + + it("can convert cookies to string", () => { + const cookie = new Bun.Cookie("name", "value", { + path: "/path", + domain: "example.com", + secure: true, + sameSite: "lax", + }); + + const cookieStr = cookie.toString(); + expect(cookieStr).toMatchInlineSnapshot(`"name=value; Domain=example.com; Path=/path; Secure; SameSite=Lax"`); + }); + + it("correctly handles toJSON methods", () => { + // Create a Cookie and test toJSON + const cookie = new Bun.Cookie("name", "value", { + path: "/test", + domain: "example.org", + secure: true, + sameSite: "lax", + expires: new Date("2025-03-21T12:00:00Z"), + }); + + const cookieJSON = cookie.toJSON(); + expect(cookieJSON).toBeTypeOf("object"); + expect(cookieJSON.name).toBe("name"); + expect(cookieJSON.value).toBe("value"); + expect(cookieJSON.path).toBe("/test"); + expect(cookieJSON.domain).toBe("example.org"); + expect(cookieJSON.secure).toBe(true); + expect(cookieJSON).toMatchInlineSnapshot(` + { + "domain": "example.org", + "expires": 2025-03-21T12:00:00.000Z, + "httpOnly": false, + "name": "name", + "partitioned": false, + "path": "/test", + "sameSite": "lax", + "secure": true, + "value": "value", + } + `); + + // Create a CookieMap and test toJSON + const cookieMap = new Bun.CookieMap("a=1; b=2; c=3"); + + const mapJSON = cookieMap.toJSON(); + expect(mapJSON).toBeInstanceOf(Object); + expect([...Object.keys(mapJSON)].length).toBe(3); + + for (const entry of Object.entries(mapJSON)) { + expect(entry.length).toBe(2); + expect(entry[0]).toBeTypeOf("string"); + expect(entry[1]).toBeTypeOf("string"); + } + + // Verify JSON.stringify works as expected + const jsonString = JSON.stringify(cookie); + expect(jsonString).toBeTypeOf("string"); + const parsed = JSON.parse(jsonString); + expect(parsed.name).toBe("name"); + expect(parsed.value).toBe("value"); + }); +}); diff --git a/test/js/bun/util/cookie-server/server.ts b/test/js/bun/util/cookie-server/server.ts new file mode 100644 index 0000000000..25a46cc391 --- /dev/null +++ b/test/js/bun/util/cookie-server/server.ts @@ -0,0 +1,37 @@ +import { CookieMap } from "bun"; + +function mainHTML(cookies: CookieMap) { + return ` + + + Hello World. Your cookies are: +
    + ${Array.from(cookies.entries()) + .map( + ([, cookie]) => + `
  • ${Bun.escapeHTML(cookie.name)}: ${Bun.escapeHTML(cookie.value)}
  • `, + ) + .join("\n")} +
  • +
    + +
    +
  • +
+ + + `; +} + +Bun.serve({ + port: 3000, + routes: { + "/": req => new Response(mainHTML(req.cookies), { headers: { "content-type": "text/html" } }), + "/update-cookies": req => { + const cookies = req.cookies; + cookies.set("test", "test"); + // return new Response("Cookies updated"); + return Response.redirect("/"); + }, + }, +}); diff --git a/test/js/bun/util/cookie.test.js b/test/js/bun/util/cookie.test.js new file mode 100644 index 0000000000..dc92a4da3c --- /dev/null +++ b/test/js/bun/util/cookie.test.js @@ -0,0 +1,643 @@ +import { test, expect, describe, it } from "bun:test"; + +describe("Bun.Cookie", () => { + test("can create a cookie", () => { + const cookie = new Bun.Cookie("name", "value"); + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.path).toBe("/"); + expect(cookie.domain).toBe(null); + expect(cookie.expires).toBe(undefined); + expect(cookie.secure).toBe(false); + expect(cookie.sameSite).toBe("lax"); + }); + + test("can create a cookie with options", () => { + const cookie = new Bun.Cookie("name", "value", { + domain: "example.com", + path: "/foo", + expires: 123456789, + secure: true, + sameSite: "strict", + }); + + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.domain).toBe("example.com"); + expect(cookie.path).toBe("/foo"); + expect(cookie.expires).toEqual(new Date(123456789000)); + expect(cookie.secure).toBe(true); + expect(cookie.sameSite).toBe("strict"); + }); + + test("stringify a cookie", () => { + const cookie = new Bun.Cookie("name", "value", { + domain: "example.com", + path: "/foo", + secure: true, + sameSite: "lax", + }); + + expect(cookie.toString()).toBe("name=value; Domain=example.com; Path=/foo; Secure; SameSite=Lax"); + }); + + test("parse a cookie string", () => { + const cookie = Bun.Cookie.parse("name=value; Domain=example.com; Path=/foo; Secure; SameSite=Lax"); + + expect(cookie.name).toBe("name"); + expect(cookie.value).toBe("value"); + expect(cookie.domain).toBe("example.com"); + expect(cookie.path).toBe("/foo"); + expect(cookie.secure).toBe(true); + expect(cookie.sameSite).toBe("lax"); + }); + + test("toJSON", () => { + const cookie = new Bun.Cookie("name", "value", { + domain: "example.com", + path: "/foo", + }); + expect(cookie.toJSON()).toEqual({ + name: "name", + value: "value", + domain: "example.com", + path: "/foo", + secure: false, + sameSite: "lax", + httpOnly: false, + partitioned: false, + }); + }); +}); + +describe("Bun.CookieMap", () => { + test("can create an empty cookie map", () => { + const cookieMap = new Bun.CookieMap(); + expect(cookieMap.size).toBe(0); + }); + + test("can create a cookie map from a string", () => { + const cookieMap = new Bun.CookieMap("name=value; foo=bar"); + expect(cookieMap.size).toBe(2); + expect(cookieMap.has("name")).toBe(true); + expect(cookieMap.has("foo")).toBe(true); + expect(cookieMap.get("name")).toBe("value"); + expect(cookieMap.get("foo")).toBe("bar"); + }); + + test("can create a cookie map from an object", () => { + const cookieMap = new Bun.CookieMap({ + name: "value", + foo: "bar", + }); + + expect(cookieMap.size).toBe(2); + expect(cookieMap.has("name")).toBe(true); + expect(cookieMap.has("foo")).toBe(true); + expect(cookieMap.get("name")).toBe("value"); + expect(cookieMap.get("foo")).toBe("bar"); + }); + + test("can create a cookie map from pairs", () => { + const cookieMap = new Bun.CookieMap([ + ["name", "value"], + ["foo", "bar"], + ]); + + expect(cookieMap.size).toBe(2); + expect(cookieMap.has("name")).toBe(true); + expect(cookieMap.has("foo")).toBe(true); + expect(cookieMap.get("name")).toBe("value"); + expect(cookieMap.get("foo")).toBe("bar"); + }); + + test("can set and get cookies", () => { + const cookieMap = new Bun.CookieMap(); + + cookieMap.set("name", "value"); + expect(cookieMap.size).toBe(1); + expect(cookieMap.has("name")).toBe(true); + expect(cookieMap.get("name")).toBe("value"); + + cookieMap.set("foo", "bar"); + expect(cookieMap.size).toBe(2); + expect(cookieMap.has("foo")).toBe(true); + expect(cookieMap.get("foo")).toBe("bar"); + }); + + test("can set cookies with a Cookie object", () => { + const cookieMap = new Bun.CookieMap(); + const cookie = new Bun.Cookie("name", "value", { + domain: "example.com", + path: "/foo", + secure: true, + sameSite: "lax", + }); + + cookieMap.set(cookie); + expect(cookieMap.size).toBe(1); + expect(cookieMap.has("name")).toBe(true); + + expect(cookieMap.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "name=value; Domain=example.com; Path=/foo; Secure; SameSite=Lax", + ] + `); + + expect(cookieMap.get("name")).toBe("value"); + }); + + test("can delete cookies", () => { + const cookieMap = new Bun.CookieMap("name=value; foo=bar"); + expect(cookieMap.size).toBe(2); + + cookieMap.delete("name"); + expect(cookieMap.size).toBe(1); + expect(cookieMap.has("name")).toBe(false); + expect(cookieMap.has("foo")).toBe(true); + + cookieMap.delete("foo"); + expect(cookieMap.size).toBe(0); + expect(cookieMap.has("foo")).toBe(false); + }); + + test("can delete cookies with options", () => { + const cookieMap = new Bun.CookieMap(); + cookieMap.set( + new Bun.Cookie("name", "value", { + domain: "example.com", + path: "/foo", + }), + ); + + cookieMap.delete({ + name: "name", + domain: "example.com", + path: "/foo", + }); + + expect(cookieMap.toSetCookieHeaders()).toMatchInlineSnapshot(` + [ + "name=; Domain=example.com; Path=/foo; Expires=Fri, 1 Jan 1970 00:00:00 -0000; SameSite=Lax", + ] + `); + + expect(cookieMap.size).toBe(0); + }); + + test("can get all cookies with the same name", () => { + const cookieMap = new Bun.CookieMap(); + cookieMap.set( + new Bun.Cookie("name", "value1", { + domain: "example.com", + path: "/foo", + }), + ); + cookieMap.set( + new Bun.Cookie("name", "value2", { + domain: "example.org", + path: "/bar", + }), + ); + + // Since we're overwriting cookies with the same name, + // the size should still be 1 + expect(cookieMap.size).toBe(1); + + // But this would work if we didn't overwrite + // const cookies = cookieMap.getAll("name"); + // expect(cookies.length).toBe(2); + }); + + test("supports iteration", () => { + const cookieMap = new Bun.CookieMap("name=value; foo=bar"); + + const entries = Array.from(cookieMap.entries()); + expect(entries).toMatchInlineSnapshot(` + [ + [ + "name", + "value", + ], + [ + "foo", + "bar", + ], + ] + `); + }); + + test("toJSON", () => { + const cookieMap = new Bun.CookieMap("name=value; foo=bar"); + expect(JSON.stringify(cookieMap, null, 2)).toMatchInlineSnapshot(` + "{ + "name": "value", + "foo": "bar" + }" + `); + }); +}); + +const cookie = { + parse: str => { + return Object.fromEntries(new Bun.CookieMap(str).entries()); + }, + serialize: (name, value, options) => { + const cookie = new Bun.Cookie(name, value, options); + return cookie.toString(); + }, +}; + +// (The MIT License) + +// Copyright (c) 2012-2014 Roman Shtylman +// Copyright (c) 2015 Douglas Christopher Wilson + +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// 'Software'), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: + +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +describe("cookie.parse(str)", function () { + it("should parse cookie string to object", function () { + expect(cookie.parse("foo=bar")).toEqual({ foo: "bar" }); + expect(cookie.parse("foo=123")).toEqual({ foo: "123" }); + }); + + it("should ignore OWS", function () { + expect(cookie.parse("FOO = bar; baz = raz")).toEqual({ + FOO: "bar", + baz: "raz", + }); + }); + + it("should parse cookie with empty value", function () { + expect(cookie.parse("foo=; bar=")).toEqual({ foo: "", bar: "" }); + }); + + it("should parse cookie with minimum length", function () { + expect(cookie.parse("f=")).toEqual({ f: "" }); + expect(cookie.parse("f=;b=")).toEqual({ f: "", b: "" }); + }); + + it("should URL-decode values", function () { + expect(cookie.parse('foo="bar=123456789&name=Magic+Mouse"')).toEqual({ + foo: '"bar=123456789&name=Magic+Mouse"', + }); + + expect(cookie.parse("email=%20%22%2c%3b%2f")).toEqual({ email: ' ",;/' }); + }); + + it.failing("should trim whitespace around key and value", function () { + expect(cookie.parse(' foo = "bar" ')).toEqual({ foo: '"bar"' }); + expect(cookie.parse(" foo = bar ; fizz = buzz ")).toEqual({ + foo: "bar", + fizz: "buzz", + }); + expect(cookie.parse(' foo = " a b c " ')).toEqual({ foo: '" a b c "' }); + expect(cookie.parse(" = bar ")).toEqual({ "": "bar" }); + expect(cookie.parse(" foo = ")).toEqual({ foo: "" }); + expect(cookie.parse(" = ")).toEqual({ "": "" }); + expect(cookie.parse("\tfoo\t=\tbar\t")).toEqual({ foo: "bar" }); + }); + + it.failing("should return original value on escape error", function () { + expect(cookie.parse("foo=%1;bar=bar")).toEqual({ foo: "%1", bar: "bar" }); + }); + + it("should ignore cookies without value", function () { + expect(cookie.parse("foo=bar;fizz ; buzz")).toEqual({ foo: "bar" }); + expect(cookie.parse(" fizz; foo= bar")).toEqual({ foo: "bar" }); + }); + + it("should ignore duplicate cookies", function () { + expect(cookie.parse("foo=%1;bar=bar;foo=boo")).toEqual({ + foo: "boo", + bar: "bar", + }); + expect(cookie.parse("foo=false;bar=bar;foo=true")).toEqual({ + foo: "true", + bar: "bar", + }); + expect(cookie.parse("foo=;bar=bar;foo=boo")).toEqual({ + foo: "boo", + bar: "bar", + }); + }); + + it("should parse native properties", function () { + expect(cookie.parse("toString=foo;valueOf=bar")).toEqual({ + toString: "foo", + valueOf: "bar", + }); + }); +}); + +describe.skip("cookie.parse(str, options)", function () { + describe('with "decode" option', function () { + it("should specify alternative value decoder", function () { + expect( + cookie.parse('foo="YmFy"', { + decode: function (v) { + return Buffer.from(v, "base64").toString(); + }, + }), + ).toEqual({ foo: "bar" }); + }); + }); +}); + +describe("cookie.serialize(name, value)", function () { + it("should serialize name and value", function () { + expect(cookie.serialize("foo", "bar")).toEqual("foo=bar; SameSite=Lax"); + }); + + it.failing("should URL-encode value", function () { + expect(cookie.serialize("foo", "bar +baz")).toEqual("foo=bar%20%2Bbaz; SameSite=Lax"); + }); + + it("should serialize empty value", function () { + expect(cookie.serialize("foo", "")).toEqual("foo=; SameSite=Lax"); + }); + + it.each([ + ["foo"], + ["foo,bar"], + ["foo!bar"], + // ["foo#bar"], + ["foo$bar"], + ["foo'bar"], + ["foo*bar"], + ["foo+bar"], + ["foo-bar"], + ["foo.bar"], + // ["foo^bar"], + ["foo_bar"], + // ["foo`bar"], + // ["foo|bar"], + ["foo~bar"], + ["foo7bar"], + // ["foo/bar"], + // ["foo@bar"], + // ["foo[bar"], + // ["foo]bar"], + // ["foo:bar"], + // ["foo{bar"], + // ["foo}bar"], + // ['foo"bar'], + // ["foobar"], + // ["foo?bar"], + // ["foo\\bar"], + ])("should serialize name: %s", name => { + expect(cookie.serialize(name, "baz")).toEqual(`${name}=baz; SameSite=Lax`); + }); + + // it.each([["foo\n"], ["foo\u280a"], ["foo=bar"], ["foo;bar"], ["foo bar"], ["foo\tbar"]])( + // "should throw for invalid name: %s", + // name => { + // expect(() => cookie.serialize(name, "bar")).toThrow(/argument name is invalid/); + // }, + // ); +}); + +describe("cookie.serialize(name, value, options)", function () { + describe('with "domain" option', function () { + it.each([ + ["example.com"], + ["sub.example.com"], + [".example.com"], + ["localhost"], + [".localhost"], + ["my-site.org"], + ["localhost"], + ])("should serialize domain: %s", domain => { + expect(cookie.serialize("foo", "bar", { domain })).toEqual(`foo=bar; Domain=${domain}; SameSite=Lax`); + }); + + // it.each([ + // ["example.com\n"], + // ["sub.example.com\u0000"], + // ["my site.org"], + // ["domain..com"], + // ["example.com; Path=/"], + // ["example.com /* inject a comment */"], + // ])("should throw for invalid domain: %s", domain => { + // expect(() => cookie.serialize("foo", "bar", { domain })).toThrow(/option domain is invalid/); + // }); + }); + + describe.skip('with "encode" option', function () { + it("should specify alternative value encoder", function () { + expect( + cookie.serialize("foo", "bar", { + encode: function (v) { + return Buffer.from(v, "utf8").toString("base64"); + }, + }), + ).toEqual("foo=YmFy"); + }); + + it.each(["foo=bar", 'foo"bar', "foo,bar", "foo\\bar", "foo$bar"])("should serialize value: %s", value => { + expect(cookie.serialize("foo", value, { encode: x => x })).toEqual(`foo=${value}`); + }); + + it.each([["+\n"], ["foo bar"], ["foo\tbar"], ["foo;bar"], ["foo\u280a"]])( + "should throw for invalid value: %s", + value => { + expect(() => cookie.serialize("foo", value, { encode: x => x })).toThrow(/argument val is invalid/); + }, + ); + }); + + describe('with "expires" option', function () { + it("should throw on invalid date", function () { + expect( + cookie.serialize.bind(cookie, "foo", "bar", { expires: new Date(NaN) }), + ).toThrowErrorMatchingInlineSnapshot(`"expires must be a valid Date (or Number)"`); + }); + + it("should set expires to given date", function () { + expect( + cookie.serialize("foo", "bar", { + expires: new Date(Date.UTC(2000, 11, 24, 10, 30, 59, 900)), + }), + ).toEqual("foo=bar; Expires=Mon, 24 Dec 2000 10:30:59 -0000; SameSite=Lax"); + }); + }); + + describe('with "httpOnly" option', function () { + it("should include httpOnly flag when true", function () { + expect(cookie.serialize("foo", "bar", { httpOnly: true })).toEqual("foo=bar; HttpOnly; SameSite=Lax"); + }); + + it("should not include httpOnly flag when false", function () { + expect(cookie.serialize("foo", "bar", { httpOnly: false })).toEqual("foo=bar; SameSite=Lax"); + }); + }); + + describe('with "maxAge" option', function () { + it.failing("should throw when not a number", function () { + expect(function () { + cookie.serialize("foo", "bar", { maxAge: "buzz" }); + }).toThrow(/option maxAge is invalid/); + }); + + it.failing("should throw when Infinity", function () { + expect(function () { + cookie.serialize("foo", "bar", { maxAge: Infinity }); + }).toThrow(/option maxAge is invalid/); + }); + + it.failing("should throw when max-age is not an integer", function () { + expect(function () { + cookie.serialize("foo", "bar", { maxAge: 3.14 }); + }).toThrow(/option maxAge is invalid/); + }); + + it("should set max-age to value", function () { + expect(cookie.serialize("foo", "bar", { maxAge: 1000 })).toEqual("foo=bar; Max-Age=1000; SameSite=Lax"); + expect(cookie.serialize("foo", "bar", { maxAge: 0 })).toEqual("foo=bar; Max-Age=0; SameSite=Lax"); + }); + + it("should not set when undefined", function () { + expect(cookie.serialize("foo", "bar", { maxAge: undefined })).toEqual("foo=bar; SameSite=Lax"); + }); + }); + + describe('with "partitioned" option', function () { + it("should include partitioned flag when true", function () { + expect(cookie.serialize("foo", "bar", { partitioned: true })).toEqual("foo=bar; Partitioned; SameSite=Lax"); + }); + + it("should not include partitioned flag when false", function () { + expect(cookie.serialize("foo", "bar", { partitioned: false })).toEqual("foo=bar; SameSite=Lax"); + }); + + it("should not include partitioned flag when not defined", function () { + expect(cookie.serialize("foo", "bar", {})).toEqual("foo=bar; SameSite=Lax"); + }); + }); + + describe('with "path" option', function () { + it("should serialize path", function () { + var validPaths = [ + // "/", + "/login", + "/foo.bar/baz", + "/foo-bar", + "/foo=bar?baz", + '/foo"bar"', + "/../foo/bar", + "../foo/", + "./", + ]; + + validPaths.forEach(function (path) { + expect(cookie.serialize("foo", "bar", { path: path })).toEqual("foo=bar; Path=" + path + "; SameSite=Lax"); + }); + }); + + it.failing("should throw for invalid value", function () { + var invalidPaths = [ + "/\n", + "/foo\u0000", + "/path/with\rnewline", + "/; Path=/sensitive-data", + '/login">', + ]; + + invalidPaths.forEach(function (path) { + expect(cookie.serialize.bind(cookie, "foo", "bar", { path: path })).toThrow(/option path is invalid/); + }); + }); + }); + + // not a standard feature + describe.skip('with "priority" option', function () { + it("should throw on invalid priority", function () { + expect(function () { + cookie.serialize("foo", "bar", { priority: "foo" }); + }).toThrow(/option priority is invalid/); + }); + + it("should throw on non-string", function () { + expect(function () { + cookie.serialize("foo", "bar", { priority: 42 }); + }).toThrow(/option priority is invalid/); + }); + + it("should set priority low", function () { + expect(cookie.serialize("foo", "bar", { priority: "low" })).toEqual("foo=bar; Priority=Low"); + }); + + it("should set priority medium", function () { + expect(cookie.serialize("foo", "bar", { priority: "medium" })).toEqual("foo=bar; Priority=Medium"); + }); + + it("should set priority high", function () { + expect(cookie.serialize("foo", "bar", { priority: "high" })).toEqual("foo=bar; Priority=High"); + }); + + it("should set priority case insensitive", function () { + /** @ts-expect-error */ + expect(cookie.serialize("foo", "bar", { priority: "High" })).toEqual("foo=bar; Priority=High"); + }); + }); + + describe('with "sameSite" option', function () { + it("should throw on invalid sameSite", function () { + expect(() => { + cookie.serialize("foo", "bar", { sameSite: "foo" }); + }).toThrowErrorMatchingInlineSnapshot(`"Invalid sameSite value. Must be 'strict', 'lax', or 'none'"`); + }); + + it("should set sameSite strict", function () { + expect(cookie.serialize("foo", "bar", { sameSite: "strict" })).toEqual("foo=bar; SameSite=Strict"); + }); + + it("should set sameSite lax", function () { + expect(cookie.serialize("foo", "bar", { sameSite: "lax" })).toEqual("foo=bar; SameSite=Lax"); + }); + + it("should set sameSite none", function () { + expect(cookie.serialize("foo", "bar", { sameSite: "none" })).toEqual("foo=bar; SameSite=None"); + }); + + it.failing("should set sameSite strict when true", function () { + expect(cookie.serialize("foo", "bar", { sameSite: true })).toEqual("foo=bar; SameSite=Strict"); + }); + + it.failing("should not set sameSite when false", function () { + expect(cookie.serialize("foo", "bar", { sameSite: false })).toEqual("foo=bar"); + }); + + it.failing("should set sameSite case insensitive", function () { + expect(cookie.serialize("foo", "bar", { sameSite: "Lax" })).toEqual("foo=bar; SameSite=Lax"); + }); + }); + + describe('with "secure" option', function () { + it("should include secure flag when true", function () { + expect(cookie.serialize("foo", "bar", { secure: true })).toEqual("foo=bar; Secure; SameSite=Lax"); + }); + + it("should not include secure flag when false", function () { + expect(cookie.serialize("foo", "bar", { secure: false })).toEqual("foo=bar; SameSite=Lax"); + }); + }); +});