diff --git a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp index e553bec6e..d1ef7be83 100644 --- a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp +++ b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp @@ -1,5 +1,7 @@ #include "JsV8InspectorClient.h" #include +#include +#include #include #include #include @@ -106,6 +108,88 @@ std::string Base64Encode(const char* data, size_t length) { return encoded; } +// The IO stream helpers below follow Chromium (base::IsStringUTF8, +// base::TruncateUTF8ToByteSize, DevToolsStreamFile) so IO.read behaves the +// same as in Chrome for every client. + +// Length of the well-formed UTF-8 sequence starting at data[i] (no overlongs, +// surrogates or code points past U+10FFFF), or 0 if it is ill-formed or cut +// off at `length`. +size_t DecodeUtf8(const uint8_t* data, size_t i, size_t length, uint32_t& codePoint) { + uint8_t lead = data[i]; + if (lead < 0x80) { + codePoint = lead; + return 1; + } + if (lead < 0xC2 || lead > 0xF4) { + return 0; + } + static constexpr uint32_t kMinCodePoint[] = {0, 0, 0x80, 0x800, 0x10000}; + size_t count = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : 2; + uint32_t min = kMinCodePoint[count]; + codePoint = lead & (0x7F >> count); + if (length - i < count) { + return 0; + } + for (size_t k = 1; k < count; ++k) { + uint8_t trail = data[i + k]; + if ((trail & 0xC0) != 0x80) { + return 0; + } + codePoint = (codePoint << 6) | (trail & 0x3F); + } + if (codePoint < min || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + return 0; + } + return count; +} + +// base::IsValidCharacter: excludes noncharacters as well as surrogates. +bool IsValidCharacter(uint32_t c) { + return c < 0xD800 || (c >= 0xE000 && c < 0xFDD0) || + (c > 0xFDEF && c <= 0x10FFFF && (c & 0xFFFE) != 0xFFFE); +} + +bool IsStringUTF8(const std::string& value) { + const auto* data = reinterpret_cast(value.data()); + for (size_t i = 0; i < value.size();) { + uint32_t codePoint; + size_t count = DecodeUtf8(data, i, value.size(), codePoint); + if (count == 0 || !IsValidCharacter(codePoint)) { + return false; + } + i += count; + } + return true; +} + +// base::TruncateUTF8ToByteSize: the length of the longest prefix of +// data[0, length) that ends right after a valid character, or 0 if none does. +size_t TruncateUTF8ToByteSize(const uint8_t* data, size_t length) { + for (size_t i = length; i-- > 0;) { + uint32_t codePoint; + size_t count = DecodeUtf8(data, i, length, codePoint); + if (count != 0 && IsValidCharacter(codePoint)) { + return i + count; + } + } + return 0; +} + +// CDP integers are int32: Chromium's protocol bindings reject anything else, +// though an integral double such as 5.0 still counts. +bool GetInt32(const json& value, long long& out) { + if (!value.is_number()) { + return false; + } + double number = value.get(); + if (number != std::trunc(number) || number < INT32_MIN || number > INT32_MAX) { + return false; + } + out = static_cast(number); + return true; +} + // Opt-out via nativescript.config.ts (serialized into the bundled // package.json): `android: { disableSourceMapURLRewrite: true }`, or the same // key at the top level. @@ -440,19 +524,36 @@ bool JsV8InspectorClient::handleMessageOnSocketThread(const std::string& message if (method == "IO.read" || method == "IO.close") { std::string handle; - int size = 0; - if (parsed.contains("params")) { + std::optional offset; + std::optional size; + bool validParams = true; + if (parsed.contains("params") && parsed["params"].is_object()) { const auto& params = parsed["params"]; - if (params.contains("handle") && params["handle"].is_string()) { - handle = params["handle"].get(); + if (params.contains("handle")) { + validParams &= params["handle"].is_string(); + if (params["handle"].is_string()) { + handle = params["handle"].get(); + } } - if (params.contains("size") && params["size"].is_number_integer()) { - size = params["size"].get(); + for (auto [name, value] : {std::pair{"offset", &offset}, std::pair{"size", &size}}) { + if (params.contains(name)) { + long long parsedValue = 0; + validParams &= GetInt32(params[name], parsedValue); + *value = parsedValue; + } } } - response = method == "IO.read" - ? HandleIORead(msgId, handle, size, sessionId) - : HandleIOClose(msgId, handle, sessionId); + if (!validParams) { + json error = { + {"id", msgId}, + {"error", {{"code", -32602}, {"message", "Invalid parameters"}}} + }; + response = FinishResponse(error, sessionId); + } else { + response = method == "IO.read" + ? HandleIORead(msgId, handle, offset, size, sessionId) + : HandleIOClose(msgId, handle, sessionId); + } return true; } @@ -770,13 +871,22 @@ std::string JsV8InspectorClient::HandleLoadNetworkResource(long long msgId, cons json resource; if (loaded) { + // The headers an HTTP server would send for the file. With + // Content-Length, js-debug stops reading at the exact byte count and + // sends IO.close itself; without it, it never closes the stream. + json headers = {{"Content-Length", std::to_string(content.size())}, + {"Content-Type", "application/json"}}; + bool binary = !IsStringUTF8(content); std::string handle; { std::lock_guard lock(resourceStreamsMutex_); handle = "ns-network-resource-" + std::to_string(++lastStreamId_); - resourceStreams_[handle] = {std::move(content), 0}; + resourceStreams_[handle] = {std::move(content), binary, 0}; } - resource = {{"success", true}, {"httpStatusCode", 200}, {"stream", handle}}; + resource = {{"success", true}, + {"httpStatusCode", 200}, + {"headers", std::move(headers)}, + {"stream", handle}}; } else { resource = {{"success", false}, {"netError", -6}, @@ -788,33 +898,68 @@ std::string JsV8InspectorClient::HandleLoadNetworkResource(long long msgId, cons return FinishResponse(response, sessionId); } -std::string JsV8InspectorClient::HandleIORead(long long msgId, const std::string& handle, int size, const std::string& sessionId) { - std::lock_guard lock(resourceStreamsMutex_); - auto it = resourceStreams_.find(handle); - if (it == resourceStreams_.end()) { +std::string JsV8InspectorClient::HandleIORead(long long msgId, const std::string& handle, + std::optional offset, + std::optional size, + const std::string& sessionId) { + auto error = [&](const char* message) { json error = { {"id", msgId}, - {"error", {{"code", -32602}, {"message", "Invalid stream handle"}}} + {"error", {{"code", -32602}, {"message", message}}} }; return FinishResponse(error, sessionId); + }; + + // Chromium defaults to 10 MB; smaller chunks keep socket frames and peak + // memory down on device. + constexpr long long kDefaultChunkSize = 1024 * 1024; + long long maxSize = size.value_or(kDefaultChunkSize); + if (maxSize <= 0) { + return error("Invalid max read size"); + } + + std::lock_guard lock(resourceStreamsMutex_); + auto it = resourceStreams_.find(handle); + if (it == resourceStreams_.end()) { + return error("Invalid stream handle"); } ResourceStream& stream = it->second; - constexpr size_t kDefaultChunkSize = 1024 * 1024; - size_t chunkSize = size > 0 ? static_cast(size) : kDefaultChunkSize; - size_t remaining = stream.data.size() - stream.offset; - chunkSize = std::min(chunkSize, remaining); + // A missing or negative offset continues from the previous read. + size_t position = offset.value_or(-1) >= 0 ? static_cast(*offset) : stream.lastReadPos; json result; - if (chunkSize == 0) { - // DevTools ignores any data sent alongside eof, so only signal it once - // the whole stream has been delivered. - result = {{"data", ""}, {"eof", true}, {"base64Encoded", false}}; + if (position >= stream.data.size()) { + // DevTools ignores any data sent alongside eof, so it only comes on the + // read after the last bytes. + result = {{"data", ""}, {"eof", true}, {"base64Encoded", stream.binary}}; } else { - // Base64 keeps arbitrary file bytes intact through the JSON transport. - std::string encoded = Base64Encode(stream.data.data() + stream.offset, chunkSize); - stream.offset += chunkSize; - result = {{"data", encoded}, {"eof", false}, {"base64Encoded", true}}; + const auto* bytes = reinterpret_cast(stream.data.data()) + position; + size_t count = std::min(static_cast(maxSize), stream.data.size() - position); + // Don't end a sufficiently large chunk inside a UTF-8 character: + // js-debug decodes every chunk on its own and advances its offset by + // the decoded length, so a split character is both garbled and skews + // that offset. + if (count > 6 && (bytes[count - 1] & 0x80) != 0) { + size_t truncated = TruncateUTF8ToByteSize(bytes, count); + // Nothing valid to cut at means binary data; leave it as is. + if (truncated != 0) { + count = truncated; + } + } + stream.lastReadPos = position + count; + + // A read that starts inside a character, or one of 6 bytes or fewer, + // can cut a character at an end. Chromium's JSON encoder would drop + // those bytes; sending that one chunk as base64 keeps them. + bool wholeCharacters = + (bytes[0] & 0xC0) != 0x80 && TruncateUTF8ToByteSize(bytes, count) == count; + const char* chunk = reinterpret_cast(bytes); + if (stream.binary || !wholeCharacters) { + result = {{"data", Base64Encode(chunk, count)}, {"eof", false}, {"base64Encoded", true}}; + } else { + result = {{"data", std::string(chunk, count)}, {"eof", false}, {"base64Encoded", false}}; + } } json response = {{"id", msgId}, {"result", result}}; @@ -822,11 +967,15 @@ std::string JsV8InspectorClient::HandleIORead(long long msgId, const std::string } std::string JsV8InspectorClient::HandleIOClose(long long msgId, const std::string& handle, const std::string& sessionId) { + bool closed; { std::lock_guard lock(resourceStreamsMutex_); - resourceStreams_.erase(handle); + closed = resourceStreams_.erase(handle) != 0; } - json response = {{"id", msgId}, {"result", json::object()}}; + json response = closed + ? json{{"id", msgId}, {"result", json::object()}} + : json{{"id", msgId}, + {"error", {{"code", -32602}, {"message", "Invalid stream handle"}}}}; return FinishResponse(response, sessionId); } diff --git a/test-app/runtime/src/main/cpp/JsV8InspectorClient.h b/test-app/runtime/src/main/cpp/JsV8InspectorClient.h index 8d828f1bc..37b278a27 100644 --- a/test-app/runtime/src/main/cpp/JsV8InspectorClient.h +++ b/test-app/runtime/src/main/cpp/JsV8InspectorClient.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -92,10 +93,14 @@ class JsV8InspectorClient : V8InspectorClient, v8_inspector::V8Inspector::Channe // Streams backing Network.loadNetworkResource responses, read by the // frontend through IO.read/IO.close (how Chrome DevTools fetches source - // maps from the target). Accessed from the websocket read thread. + // maps from the target). Accessed from the websocket read thread. Like + // Chromium's DevToolsIOContext, a stream lives until IO.close or until + // its frontend disconnects. struct ResourceStream { std::string data; - size_t offset = 0; + // Chromium sends valid UTF-8 as text and everything else as base64. + bool binary = false; + size_t lastReadPos = 0; }; // Live worker inspectors, keyed by their flat-protocol sessionId @@ -122,7 +127,9 @@ class JsV8InspectorClient : V8InspectorClient, v8_inspector::V8Inspector::Channe // Source map delivery to Chrome DevTools (Network.loadNetworkResource + // IO domain). V8's inspector doesn't implement these embedder domains. std::string HandleLoadNetworkResource(long long msgId, const std::string& url, const std::string& sessionId); - std::string HandleIORead(long long msgId, const std::string& handle, int size, const std::string& sessionId); + std::string HandleIORead(long long msgId, const std::string& handle, + std::optional offset, std::optional size, + const std::string& sessionId); std::string HandleIOClose(long long msgId, const std::string& handle, const std::string& sessionId); std::map resourceStreams_;