diff --git a/crates/native-sidecar/tests/builtin_conformance.rs b/crates/native-sidecar/tests/builtin_conformance.rs index aea2370d7a..2e219e05f6 100644 --- a/crates/native-sidecar/tests/builtin_conformance.rs +++ b/crates/native-sidecar/tests/builtin_conformance.rs @@ -69,6 +69,7 @@ const BUILTIN_CONFORMANCE_CASES: &[&str] = &[ "events", "stream", "buffer", + "global_base64", "url", "stdlib_polyfill", "web_streams", @@ -3834,6 +3835,45 @@ fn buffer_concat_truncation_matches_host_node() { run_isolated_builtin_conformance_test("buffer-concat-truncation"); } +fn global_base64_conformance_matches_host_node() { + assert_conformance( + "global_base64", + r#" +function describeError(callback) { + try { + callback(); + return { threw: false }; + } catch (error) { + return { + threw: true, + name: error?.name ?? null, + code: error?.code ?? null, + message: error?.message ?? null, + }; + } +} + +console.log(JSON.stringify({ + atobText: atob("aGVsbG8="), + atobUnpaddedSingleByte: atob("YQ"), + atobUnpaddedTwoBytes: atob("YWI"), + atobWhitespace: atob(" YQ== \n"), + atobNumberCoercion: atob(1234), + btoaText: btoa("hello"), + btoaNumberCoercion: btoa(1234), + invalidAtob: describeError(() => atob("%%%")), + invalidShortAtob: describeError(() => atob("Y")), + invalidPaddingAtob: describeError(() => atob("AA=A")), + invalidPartialPaddingAtob: describeError(() => atob("YQ=")), + invalidOnlyPaddingAtob: describeError(() => atob("==")), + invalidUrlSafeDashAtob: describeError(() => atob("AA-A")), + invalidUrlSafeUnderscoreAtob: describeError(() => atob("AA_A")), + invalidBtoa: describeError(() => btoa("✓")), +})); +"#, + ); +} + fn mkdtemp_sync_collision_safe_matches_host_node_impl() { let cwd = temp_dir("mkdtemp-sync-collision-safe"); let entrypoint = cwd.join("entry.mjs"); @@ -4874,6 +4914,7 @@ fn run_named_case(case_name: &str) { "events" => events_conformance_matches_host_node(), "stream" => stream_conformance_matches_host_node(), "buffer" => buffer_conformance_matches_host_node(), + "global_base64" => global_base64_conformance_matches_host_node(), "url" => url_conformance_matches_host_node(), "stdlib_polyfill" => stdlib_polyfill_conformance_matches_host_node(), "web_streams" => web_streams_conformance_matches_host_node(), diff --git a/packages/build-tools/bridge-src/builtins/process.ts b/packages/build-tools/bridge-src/builtins/process.ts index 726e7f66ce..c207d71691 100644 --- a/packages/build-tools/bridge-src/builtins/process.ts +++ b/packages/build-tools/bridge-src/builtins/process.ts @@ -1141,6 +1141,9 @@ function setupGlobals() { g.Event = Event; g.CustomEvent = CustomEvent; g.EventTarget = EventTarget; + if (typeof g.DOMException === "undefined") { + g.DOMException = SandboxDOMException; + } if (typeof g.Buffer === "undefined") { g.Buffer = Buffer3; } @@ -1161,9 +1164,36 @@ function setupGlobals() { installBuiltinUtilFormatWithOptions(builtinUtilModule); if (typeof g.atob === "undefined" || typeof g.btoa === "undefined") { const base64 = require_base64_js(); + const createInvalidCharacterError = (message = "Invalid character") => { + const error = new g.DOMException(message, "InvalidCharacterError"); + if (error.code === 0) error.code = 5; + return error; + }; if (typeof g.atob === "undefined") { g.atob = (value) => { - const bytes = base64.toByteArray(String(value)); + // WHATWG forgiving-base64 decode accepts ASCII whitespace and + // unpadded input, but rejects the base64url alphabet. base64-js + // implements RFC 4648 section 4 instead, so normalize the input and + // reject URL-safe characters before handing it over. + const input = String(value).replace(/[\t\n\f\r ]+/g, ""); + const hasPadding = input.includes("="); + if (/[^A-Za-z0-9+/=]/.test(input) || /={3,}/.test(input) || /=[^=]/.test(input)) { + throw createInvalidCharacterError(); + } + const remainder = input.length % 4; + if (remainder === 1) { + throw createInvalidCharacterError("The string to be decoded is not correctly encoded."); + } + if (hasPadding && remainder !== 0) { + throw createInvalidCharacterError(); + } + const normalizedInput = !hasPadding && remainder === 2 ? `${input}==` : !hasPadding && remainder === 3 ? `${input}=` : input; + let bytes = new Uint8Array(0); + try { + bytes = base64.toByteArray(normalizedInput); + } catch { + throw createInvalidCharacterError(); + } let decoded = ""; for (const byte of bytes) { decoded += String.fromCharCode(byte); @@ -1178,7 +1208,7 @@ function setupGlobals() { for (let index = 0; index < input.length; index += 1) { const code = input.charCodeAt(index); if (code > 255) { - throw new TypeError("Invalid character"); + throw createInvalidCharacterError(); } bytes[index] = code; } @@ -1195,9 +1225,6 @@ function setupGlobals() { if (typeof g.CryptoKey === "undefined") { g.CryptoKey = SandboxCryptoKey; } - if (typeof g.DOMException === "undefined") { - g.DOMException = SandboxDOMException; - } if (typeof g.crypto === "undefined") { g.crypto = builtinCryptoModule; } else {