diff --git a/src/support/CMakeLists.txt b/src/support/CMakeLists.txt index 3fc559ff36b..ef03af494ae 100644 --- a/src/support/CMakeLists.txt +++ b/src/support/CMakeLists.txt @@ -10,6 +10,7 @@ set(support_SOURCES int128.cpp intervals.cpp istring.cpp + js-embedded-module.cpp json.cpp name.cpp path.cpp diff --git a/src/support/js-embedded-module.cpp b/src/support/js-embedded-module.cpp new file mode 100644 index 00000000000..ad59183f6c5 --- /dev/null +++ b/src/support/js-embedded-module.cpp @@ -0,0 +1,287 @@ +/* + * Copyright 2026 WebAssembly Community Group participants + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "support/js-embedded-module.h" + +#include +#include + +namespace wasm { + +namespace { + +// Advances `pos` past a `//` single-line comment (leaving `pos` at the newline +// character or end of `js`). Assumes `js.substr(pos, 2) == "//"`. +void skipSingleLineComment(std::string_view js, size_t& pos) { + pos += 2; + while (pos < js.size() && js[pos] != '\n') { + ++pos; + } +} + +// Advances `pos` past a `/* ... */` multi-line comment (or to the end of `js` +// if the comment is unterminated). Assumes `js.substr(pos, 2) == "/*"`. +void skipMultiLineComment(std::string_view js, size_t& pos) { + pos += 2; + while (pos + 1 < js.size() && !(js[pos] == '*' && js[pos + 1] == '/')) { + ++pos; + } + if (pos + 1 < js.size()) { + pos += 2; + } else { + pos = js.size(); + } +} + +// Advances `pos` past any contiguous ASCII whitespace, single-line comments, +// and multi-line comments. +void skipWhitespaceAndComments(std::string_view js, size_t& pos) { + while (pos < js.size()) { + if (std::isspace(static_cast(js[pos]))) { + ++pos; + } else if (pos + 1 < js.size() && js[pos] == '/' && js[pos + 1] == '/') { + skipSingleLineComment(js, pos); + } else if (pos + 1 < js.size() && js[pos] == '/' && js[pos + 1] == '*') { + skipMultiLineComment(js, pos); + } else { + break; + } + } +} + +// Parses a single integer literal representing an 8-bit byte at `pos` (in +// decimal, `0x` hex, `0o` octal, or `0b` binary, with optional `+`/`-` sign and +// `_` numeric separators). Accepts unsigned values in [0, 255] and signed +// negative values in [-128, -1]. On success, advances `pos` past the literal, +// writes the byte to `outByte`, and returns true. +bool parseByteLiteral(std::string_view js, size_t& pos, char& outByte) { + if (pos >= js.size()) { + return false; + } + + bool negative = false; + if (js[pos] == '-' || js[pos] == '+') { + negative = (js[pos] == '-'); + ++pos; + if (pos >= js.size()) { + return false; + } + } + + int base = 10; + if (js[pos] == '0' && pos + 1 < js.size()) { + char next = js[pos + 1]; + if (next == 'x' || next == 'X') { + base = 16; + pos += 2; + } else if (next == 'o' || next == 'O') { + base = 8; + pos += 2; + } else if (next == 'b' || next == 'B') { + base = 2; + pos += 2; + } + } + + uint32_t value = 0; + bool hasDigits = false; + while (pos < js.size()) { + char c = js[pos]; + if (c == '_') { + ++pos; + continue; + } + int digit = -1; + if (c >= '0' && c <= '9') { + digit = c - '0'; + } else if (c >= 'a' && c <= 'f') { + digit = 10 + (c - 'a'); + } else if (c >= 'A' && c <= 'F') { + digit = 10 + (c - 'A'); + } else { + break; + } + if (digit >= base) { + return false; + } + hasDigits = true; + value = value * base + digit; + if ((!negative && value > 255) || (negative && value > 128)) { + return false; + } + ++pos; + } + + if (!hasDigits) { + return false; + } + + // Ensure the literal is not immediately followed by an identifier character. + if (pos < js.size() && + (std::isalnum(static_cast(js[pos])) || js[pos] == '_')) { + return false; + } + + if (negative) { + outByte = static_cast(static_cast(-static_cast(value))); + } else { + outByte = static_cast(static_cast(value)); + } + return true; +} + +// Attempts to parse a JavaScript array literal starting at index `start` +// (`js[start] == '['`) as an embedded WebAssembly module. Rejects early if the +// first 4 elements do not match the `\0asm` magic bytes (`0x00, 0x61, 0x73, +// 0x6d`) or if any element is not a valid byte literal. +bool tryParseWasmByteArray(std::string_view js, + size_t start, + EmbeddedModule& outModule) { + static constexpr uint8_t WasmMagic[4] = {0x00, 0x61, 0x73, 0x6d}; + + size_t pos = start + 1; + std::vector bytes; + + while (true) { + skipWhitespaceAndComments(js, pos); + if (pos >= js.size()) { + return false; + } + if (js[pos] == ']') { + if (bytes.size() < 4) { + return false; + } + outModule = EmbeddedModule{start, pos + 1, std::move(bytes)}; + return true; + } + + char byteVal = 0; + if (!parseByteLiteral(js, pos, byteVal)) { + return false; + } + + // Early rejection if the first 4 bytes do not match \0asm. + if (bytes.size() < 4 && + static_cast(byteVal) != WasmMagic[bytes.size()]) { + return false; + } + + bytes.push_back(byteVal); + + skipWhitespaceAndComments(js, pos); + if (pos >= js.size()) { + return false; + } + if (js[pos] == ',') { + ++pos; + } else if (js[pos] != ']') { + return false; + } + } +} + +} // namespace + +std::vector findEmbeddedModules(std::string_view js) { + std::vector modules; + size_t pos = 0; + + while (pos < js.size()) { + char c = js[pos]; + + // Skip single-line and multi-line comments. + if (c == '/' && pos + 1 < js.size()) { + if (js[pos + 1] == '/') { + skipSingleLineComment(js, pos); + continue; + } + if (js[pos + 1] == '*') { + skipMultiLineComment(js, pos); + continue; + } + } + + // Skip string and template literals. + if (c == '\'' || c == '"' || c == '`') { + char quote = c; + ++pos; + while (pos < js.size()) { + if (js[pos] == '\\') { + pos += 2; + continue; + } + if (js[pos] == quote) { + ++pos; + break; + } + if (quote != '`' && (js[pos] == '\n' || js[pos] == '\r')) { + // Single- and double-quoted JS strings cannot span unescaped lines. + ++pos; + break; + } + ++pos; + } + continue; + } + + if (c == '[') { + EmbeddedModule mod; + if (tryParseWasmByteArray(js, pos, mod)) { + pos = mod.end; + modules.push_back(std::move(mod)); + continue; + } + } + + ++pos; + } + + return modules; +} + +std::string formatByteArray(const std::vector& bytes) { + if (bytes.empty()) { + return "[]"; + } + + static constexpr char HexDigits[] = "0123456789abcdef"; + std::string out = "[\n"; + // Each byte takes ~6 chars ("0x00, "), plus indentation and brackets. + out.reserve(bytes.size() * 6 + (bytes.size() / 16 + 1) * 4 + 4); + + for (size_t i = 0; i < bytes.size(); ++i) { + if (i % 16 == 0) { + out += " "; + } + uint8_t b = static_cast(bytes[i]); + out += "0x"; + out += HexDigits[b >> 4]; + out += HexDigits[b & 0x0f]; + if (i + 1 < bytes.size()) { + if ((i + 1) % 16 == 0) { + out += ",\n"; + } else { + out += ", "; + } + } else { + out += "\n]"; + } + } + + return out; +} + +} // namespace wasm diff --git a/src/support/js-embedded-module.h b/src/support/js-embedded-module.h new file mode 100644 index 00000000000..0461e034b04 --- /dev/null +++ b/src/support/js-embedded-module.h @@ -0,0 +1,54 @@ +/* + * Copyright 2026 WebAssembly Community Group participants + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Utilities for dealing with Wasm modules embedded as literal byte arrays in JS +// files. + +#ifndef wasm_support_js_embedded_module_h +#define wasm_support_js_embedded_module_h + +#include +#include +#include +#include + +namespace wasm { + +struct EmbeddedModule { + // Index of '[' in the JS source string. + size_t start; + // Index immediately after ']' in the JS source string. + size_t end; + // Decoded binary Wasm module bytes. + std::vector bytes; + + bool operator==(const EmbeddedModule& other) const { + return start == other.start && end == other.end && bytes == other.bytes; + } +}; + +// Scans JavaScript source code `js` and returns all embedded byte array +// literals (`[...]`) whose initial bytes match the WebAssembly magic header +// (`0x00, 0x61, 0x73, 0x6d`). +std::vector findEmbeddedModules(std::string_view js); + +// Formats a binary WebAssembly byte buffer as a JavaScript hex byte array +// literal (`[\n 0x00, 0x61, 0x73, 0x6d, ...\n]`). +std::string formatByteArray(const std::vector& bytes); + +} // namespace wasm + +#endif // wasm_support_js_embedded_module_h diff --git a/test/gtest/CMakeLists.txt b/test/gtest/CMakeLists.txt index b0a73d21d1e..e6be5406d7b 100644 --- a/test/gtest/CMakeLists.txt +++ b/test/gtest/CMakeLists.txt @@ -24,6 +24,7 @@ set(unittest_SOURCES interpreter.cpp intervals.cpp istring.cpp + js-embedded-module.cpp json.cpp lattices.cpp local-graph.cpp diff --git a/test/gtest/js-embedded-module.cpp b/test/gtest/js-embedded-module.cpp new file mode 100644 index 00000000000..6b6c96fd57d --- /dev/null +++ b/test/gtest/js-embedded-module.cpp @@ -0,0 +1,174 @@ +/* + * Copyright 2026 WebAssembly Community Group participants + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "support/js-embedded-module.h" +#include "gtest/gtest.h" + +using namespace wasm; + +namespace { + +std::vector makeBytes(std::initializer_list list) { + std::vector result; + result.reserve(list.size()); + for (uint8_t b : list) { + result.push_back(static_cast(b)); + } + return result; +} + +} // namespace + +TEST(JsEmbeddedModuleTest, HexArrayLiteral) { + std::string js = R"( + let bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00 + ]); + )"; + + auto modules = findEmbeddedModules(js); + ASSERT_EQ(modules.size(), 1u); + EXPECT_EQ(modules[0].bytes, + makeBytes({0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00})); + EXPECT_EQ(js[modules[0].start], '['); + EXPECT_EQ(js[modules[0].end - 1], ']'); +} + +TEST(JsEmbeddedModuleTest, VariousNumericFormatsAndTrailingComma) { + std::string js = R"( + var raw = [ + 0, 97, 115, 109, + 0o1, 0b0000_0000, +1, -1, -128, 0Xff, + ]; + )"; + + auto modules = findEmbeddedModules(js); + ASSERT_EQ(modules.size(), 1u); + EXPECT_EQ( + modules[0].bytes, + makeBytes({0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x01, 0xff, 0x80, 0xff})); +} + +TEST(JsEmbeddedModuleTest, InnerCommentsAndWhitespace) { + std::string js = R"( + const buf = [ + // Wasm magic + 0x00, 0x61, /* 'a' */ 0x73, 0x6d, + /* version 1 */ + 0x01, 0x00, 0x00, 0x00 // end of header + /* trailing comment */ + ]; + )"; + + auto modules = findEmbeddedModules(js); + ASSERT_EQ(modules.size(), 1u); + EXPECT_EQ(modules[0].bytes, + makeBytes({0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00})); +} + +TEST(JsEmbeddedModuleTest, IgnoresStringsAndComments) { + std::string js = R"( + // [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x01] + /* + [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x02] + */ + const s1 = "[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x03]"; + const s2 = 'escaped \' [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x04]'; + const s3 = `multiline template \` + [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x05] + `; + const actual = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x06]; + )"; + + auto modules = findEmbeddedModules(js); + ASSERT_EQ(modules.size(), 1u); + EXPECT_EQ(modules[0].bytes, + makeBytes({0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x06})); +} + +TEST(JsEmbeddedModuleTest, EarlyRejectionAndNestedArrays) { + std::string js = R"( + let empty = []; + let tooShort = [0x00, 0x61, 0x73]; + let nonWasm = [1, 2, 3, 4, 5, 6, 7, 8]; + let wrongMagic = [0x00, 0x61, 0x73, 0x6e, 1, 0, 0, 1]; + let outOfRangePos = [0x00, 0x61, 0x73, 0x6d, 256, 0, 0, 2]; + let outOfRangeNeg = [0x00, 0x61, 0x73, 0x6d, -129, 0, 0, 3]; + let nonConstant = [0x00, 0x61, 0x73, 0x6d, foo(), 0, 0, 4]; + let nested = [[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x05]]; + )"; + + auto modules = findEmbeddedModules(js); + ASSERT_EQ(modules.size(), 1u); + EXPECT_EQ(modules[0].bytes, + makeBytes({0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x05})); + EXPECT_EQ(js.substr(modules[0].start, modules[0].end - modules[0].start), + "[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x05]"); +} + +TEST(JsEmbeddedModuleTest, MultiModuleReverseOrderSplicing) { + std::string js = R"( + const m0 = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0a]); + const m1 = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0b]); + const m2 = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0c]); + )"; + + auto modules = findEmbeddedModules(js); + ASSERT_EQ(modules.size(), 3u); + EXPECT_EQ(static_cast(modules[0].bytes.back()), 0x0au); + EXPECT_EQ(static_cast(modules[1].bytes.back()), 0x0bu); + EXPECT_EQ(static_cast(modules[2].bytes.back()), 0x0cu); + + // Replace from last to first without rescanning, verifying earlier offsets do + // not drift. + auto reducedBytes = + makeBytes({0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}); + std::string formatted = formatByteArray(reducedBytes); + + for (size_t i = modules.size(); i > 0; --i) { + const auto& mod = modules[i - 1]; + js.replace(mod.start, mod.end - mod.start, formatted); + } + + auto rescanned = findEmbeddedModules(js); + ASSERT_EQ(rescanned.size(), 3u); + for (const auto& mod : rescanned) { + EXPECT_EQ(mod.bytes, reducedBytes); + } +} + +TEST(JsEmbeddedModuleTest, FormatByteArray) { + EXPECT_EQ(formatByteArray({}), "[]"); + + auto bytes24 = makeBytes({0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, 0x03, 0x02, + 0x01, 0x00, 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b}); + std::string expected24 = + "[\n" + " 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60, " + "0x00, 0x00, 0x03, 0x02,\n" + " 0x01, 0x00, 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b\n" + "]"; + EXPECT_EQ(formatByteArray(bytes24), expected24); + + // Round-trip formatted array back through findEmbeddedModules. + auto parsed = findEmbeddedModules(expected24); + ASSERT_EQ(parsed.size(), 1u); + EXPECT_EQ(parsed[0].bytes, bytes24); +}