From 47a2c9463ed60201367479bf4173793546df5488 Mon Sep 17 00:00:00 2001 From: Thomas Lively Date: Fri, 18 Sep 2026 15:38:25 -0700 Subject: [PATCH] Add a utility for handling modules embedded in JS Add a function that will search JS source for literal byte arrays that might contain Wasm modules. Handle skipping strings and comments, and handle signed and unsigned byte values in decimal, octal, and hex formats. Reject byte arrays that do not begin with the Wasm magic values, but do not otherwise verify that the bytes encode a well-formed or valid Wasm module. Also add a function for printing a byte sequence as a JS byte array, formatted with 16 hex values per line. A future PR will use these functions in wasm-reduce to reduce Wasm modules embedded in JS files. --- src/support/CMakeLists.txt | 1 + src/support/js-embedded-module.cpp | 287 +++++++++++++++++++++++++++++ src/support/js-embedded-module.h | 54 ++++++ test/gtest/CMakeLists.txt | 1 + test/gtest/js-embedded-module.cpp | 174 +++++++++++++++++ 5 files changed, 517 insertions(+) create mode 100644 src/support/js-embedded-module.cpp create mode 100644 src/support/js-embedded-module.h create mode 100644 test/gtest/js-embedded-module.cpp 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); +}