mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[core] Move the JSON string escape helper into core and share it with ble_scanner
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
|
||||||
#include "esphome/core/component.h"
|
#include "esphome/core/component.h"
|
||||||
|
#include "esphome/core/helpers.h"
|
||||||
|
#include "esphome/core/string_ref.h"
|
||||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||||
#include "esphome/components/text_sensor/text_sensor.h"
|
#include "esphome/components/text_sensor/text_sensor.h"
|
||||||
|
|
||||||
@@ -19,21 +21,8 @@ class BLEScanner final : public text_sensor::TextSensor,
|
|||||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
|
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
|
||||||
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||||
// Escape special characters in the device name for valid JSON
|
// Escape special characters in the device name for valid JSON
|
||||||
const char *name = device.get_name().c_str();
|
|
||||||
char escaped_name[128];
|
char escaped_name[128];
|
||||||
size_t pos = 0;
|
json_escape_into_buffer(escaped_name, StringRef(device.get_name()));
|
||||||
for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) {
|
|
||||||
uint8_t c = static_cast<uint8_t>(*name);
|
|
||||||
if (c == '"' || c == '\\') {
|
|
||||||
escaped_name[pos++] = '\\';
|
|
||||||
escaped_name[pos++] = c;
|
|
||||||
} else if (c < 0x20) {
|
|
||||||
pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c);
|
|
||||||
} else {
|
|
||||||
escaped_name[pos++] = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
escaped_name[pos] = '\0';
|
|
||||||
|
|
||||||
char buf[256];
|
char buf[256];
|
||||||
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
|
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
#include "esphome/core/application.h"
|
#include "esphome/core/application.h"
|
||||||
#include "esphome/components/wifi/wifi_component.h"
|
#include "esphome/components/wifi/wifi_component.h"
|
||||||
#include "captive_index.h"
|
#include "captive_index.h"
|
||||||
#include "json_escape.h"
|
|
||||||
|
|
||||||
namespace esphome::captive_portal {
|
namespace esphome::captive_portal {
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <span>
|
|
||||||
|
|
||||||
#include "esphome/core/helpers.h"
|
|
||||||
#include "esphome/core/string_ref.h"
|
|
||||||
|
|
||||||
namespace esphome::captive_portal {
|
|
||||||
|
|
||||||
/// Largest number of output bytes a single input byte can expand to (a \u00XX sequence).
|
|
||||||
static constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6;
|
|
||||||
|
|
||||||
/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal.
|
|
||||||
///
|
|
||||||
/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and
|
|
||||||
/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is
|
|
||||||
/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the
|
|
||||||
/// call can be used directly as an argument.
|
|
||||||
///
|
|
||||||
/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for
|
|
||||||
/// the null terminator.
|
|
||||||
inline const char *json_escape_into_buffer(std::span<char> buf, StringRef value) {
|
|
||||||
if (buf.empty())
|
|
||||||
return "";
|
|
||||||
// Reserve one byte for the null terminator.
|
|
||||||
const size_t limit = buf.size() - 1;
|
|
||||||
size_t pos = 0;
|
|
||||||
for (char ch : value) {
|
|
||||||
auto c = static_cast<unsigned char>(ch);
|
|
||||||
// Every short form is a backslash followed by a single character, so only that character is needed here. Keeping
|
|
||||||
// it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266.
|
|
||||||
char escape = '\0';
|
|
||||||
switch (c) {
|
|
||||||
case '"':
|
|
||||||
escape = '"';
|
|
||||||
break;
|
|
||||||
case '\\':
|
|
||||||
escape = '\\';
|
|
||||||
break;
|
|
||||||
case '\n':
|
|
||||||
escape = 'n';
|
|
||||||
break;
|
|
||||||
case '\r':
|
|
||||||
escape = 'r';
|
|
||||||
break;
|
|
||||||
case '\t':
|
|
||||||
escape = 't';
|
|
||||||
break;
|
|
||||||
case '\b':
|
|
||||||
escape = 'b';
|
|
||||||
break;
|
|
||||||
case '\f':
|
|
||||||
escape = 'f';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (escape != '\0') {
|
|
||||||
if (pos + 2 > limit)
|
|
||||||
break;
|
|
||||||
buf[pos++] = '\\';
|
|
||||||
buf[pos++] = escape;
|
|
||||||
} else if (c < 0x20) {
|
|
||||||
// Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so
|
|
||||||
// the two high hex digits are always zero.
|
|
||||||
if (pos + JSON_ESCAPE_MAX_EXPANSION > limit)
|
|
||||||
break;
|
|
||||||
buf[pos++] = '\\';
|
|
||||||
buf[pos++] = 'u';
|
|
||||||
buf[pos++] = '0';
|
|
||||||
buf[pos++] = '0';
|
|
||||||
buf[pos++] = format_hex_char(static_cast<uint8_t>(c >> 4));
|
|
||||||
buf[pos++] = format_hex_char(static_cast<uint8_t>(c & 0x0F));
|
|
||||||
} else {
|
|
||||||
if (pos + 1 > limit)
|
|
||||||
break;
|
|
||||||
buf[pos++] = static_cast<char>(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf[pos] = '\0';
|
|
||||||
return buf.data();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace esphome::captive_portal
|
|
||||||
@@ -198,6 +198,68 @@ float random_float() { return static_cast<float>(random_uint32()) / static_cast<
|
|||||||
|
|
||||||
// Strings
|
// Strings
|
||||||
|
|
||||||
|
const char *json_escape_into_buffer(std::span<char> buf, StringRef value) {
|
||||||
|
if (buf.empty())
|
||||||
|
return "";
|
||||||
|
// Reserve one byte for the null terminator.
|
||||||
|
const size_t limit = buf.size() - 1;
|
||||||
|
size_t pos = 0;
|
||||||
|
for (char ch : value) {
|
||||||
|
auto c = static_cast<unsigned char>(ch);
|
||||||
|
// Every short form is a backslash followed by a single character, so only that character is needed here. Keeping
|
||||||
|
// it a char rather than a string avoids putting the sequences in read only data, which is RAM on the ESP8266.
|
||||||
|
char escape = '\0';
|
||||||
|
switch (c) {
|
||||||
|
case '"':
|
||||||
|
escape = '"';
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
escape = '\\';
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
escape = 'n';
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
escape = 'r';
|
||||||
|
break;
|
||||||
|
case '\t':
|
||||||
|
escape = 't';
|
||||||
|
break;
|
||||||
|
case '\b':
|
||||||
|
escape = 'b';
|
||||||
|
break;
|
||||||
|
case '\f':
|
||||||
|
escape = 'f';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (escape != '\0') {
|
||||||
|
if (pos + 2 > limit)
|
||||||
|
break;
|
||||||
|
buf[pos++] = '\\';
|
||||||
|
buf[pos++] = escape;
|
||||||
|
} else if (c < 0x20) {
|
||||||
|
// Remaining control characters have no short form and must be written as \u00XX. The value is below 0x20, so
|
||||||
|
// the two high hex digits are always zero.
|
||||||
|
if (pos + JSON_ESCAPE_MAX_EXPANSION > limit)
|
||||||
|
break;
|
||||||
|
buf[pos++] = '\\';
|
||||||
|
buf[pos++] = 'u';
|
||||||
|
buf[pos++] = '0';
|
||||||
|
buf[pos++] = '0';
|
||||||
|
buf[pos++] = format_hex_char(static_cast<uint8_t>(c >> 4));
|
||||||
|
buf[pos++] = format_hex_char(static_cast<uint8_t>(c & 0x0F));
|
||||||
|
} else {
|
||||||
|
if (pos + 1 > limit)
|
||||||
|
break;
|
||||||
|
buf[pos++] = static_cast<char>(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf[pos] = '\0';
|
||||||
|
return buf.data();
|
||||||
|
}
|
||||||
|
|
||||||
bool str_equals_case_insensitive(const std::string &a, const std::string &b) {
|
bool str_equals_case_insensitive(const std::string &a, const std::string &b) {
|
||||||
return strcasecmp(a.c_str(), b.c_str()) == 0;
|
return strcasecmp(a.c_str(), b.c_str()) == 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -948,6 +948,20 @@ template<typename T> constexpr T convert_little_endian(T val) {
|
|||||||
/// @name Strings
|
/// @name Strings
|
||||||
///@{
|
///@{
|
||||||
|
|
||||||
|
/// Largest number of output bytes a single input byte can expand to when JSON escaped (a \u00XX sequence).
|
||||||
|
inline constexpr size_t JSON_ESCAPE_MAX_EXPANSION = 6;
|
||||||
|
|
||||||
|
/// Copy value into buf, escaping the characters that cannot appear raw inside a JSON string literal.
|
||||||
|
///
|
||||||
|
/// Escapes " and \ along with the control characters below 0x20, using the short forms where JSON defines one and
|
||||||
|
/// \u00XX otherwise. Bytes >= 0x20 are copied verbatim, so text containing valid UTF-8 survives intact. The result is
|
||||||
|
/// always null terminated; anything that would not fit is dropped rather than written partially. Returns buf so the
|
||||||
|
/// call can be used directly as an argument.
|
||||||
|
///
|
||||||
|
/// To size buf so that no input is ever dropped, allow JSON_ESCAPE_MAX_EXPANSION bytes per input byte plus one for
|
||||||
|
/// the null terminator.
|
||||||
|
const char *json_escape_into_buffer(std::span<char> buf, StringRef value);
|
||||||
|
|
||||||
/// Compare strings for equality in case-insensitive manner.
|
/// Compare strings for equality in case-insensitive manner.
|
||||||
bool str_equals_case_insensitive(const std::string &a, const std::string &b);
|
bool str_equals_case_insensitive(const std::string &a, const std::string &b);
|
||||||
/// Compare StringRefs for equality in case-insensitive manner.
|
/// Compare StringRefs for equality in case-insensitive manner.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""Test-manifest overrides for the captive_portal C++ unit tests.
|
|
||||||
|
|
||||||
``json_escape`` lives in a standalone, dependency-free header
|
|
||||||
(``esphome/components/captive_portal/json_escape.h``). The rest of the
|
|
||||||
captive_portal component and its auto-loaded dependencies (``web_server_base``,
|
|
||||||
``ota.web_server``) do not build for the ``host`` platform that the C++ unit
|
|
||||||
test harness targets. Strip those away and replace the real schema -- which is
|
|
||||||
restricted to non-host platforms via ``cv.only_on`` and requires a
|
|
||||||
``web_server_base`` instance via ``use_id`` -- with an empty one so the host
|
|
||||||
test config validates. ``to_code`` stays suppressed (the default), so
|
|
||||||
``USE_CAPTIVE_PORTAL`` is never defined and ``captive_portal.cpp`` compiles to an
|
|
||||||
empty translation unit; only ``json_escape.h`` is exercised by the test.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import esphome.config_validation as cv
|
|
||||||
from tests.testing_helpers import ComponentManifestOverride
|
|
||||||
|
|
||||||
|
|
||||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
|
||||||
manifest.auto_load = []
|
|
||||||
manifest.dependencies = []
|
|
||||||
manifest.config_schema = cv.Schema({})
|
|
||||||
manifest.final_validate_schema = None
|
|
||||||
+15
-14
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "esphome/components/captive_portal/json_escape.h"
|
#include "esphome/core/helpers.h"
|
||||||
|
#include "esphome/core/string_ref.h"
|
||||||
|
|
||||||
namespace esphome::captive_portal::testing {
|
namespace esphome::testing {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -20,27 +21,27 @@ std::string escape(const std::string &value) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
// Plain ASCII with no special characters is passed through unchanged.
|
// Plain ASCII with no special characters is passed through unchanged.
|
||||||
TEST(CaptivePortalJsonEscape, PlainStringUnchanged) {
|
TEST(JsonEscape, PlainStringUnchanged) {
|
||||||
EXPECT_EQ(escape("MyNetwork"), "MyNetwork");
|
EXPECT_EQ(escape("MyNetwork"), "MyNetwork");
|
||||||
EXPECT_EQ(escape(""), "");
|
EXPECT_EQ(escape(""), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A double quote is escaped so it does not terminate the surrounding JSON string.
|
// A double quote is escaped so it does not terminate the surrounding JSON string.
|
||||||
TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) {
|
TEST(JsonEscape, EscapesDoubleQuote) {
|
||||||
EXPECT_EQ(escape("a\"b"), "a\\\"b");
|
EXPECT_EQ(escape("a\"b"), "a\\\"b");
|
||||||
// A double quote followed by other characters stays inside the JSON string.
|
// A double quote followed by other characters stays inside the JSON string.
|
||||||
EXPECT_EQ(escape("\">end"), "\\\">end");
|
EXPECT_EQ(escape("\">end"), "\\\">end");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A backslash is doubled so it does not start an escape sequence in the output.
|
// A backslash is doubled so it does not start an escape sequence in the output.
|
||||||
TEST(CaptivePortalJsonEscape, EscapesBackslash) {
|
TEST(JsonEscape, EscapesBackslash) {
|
||||||
EXPECT_EQ(escape("a\\b"), "a\\\\b");
|
EXPECT_EQ(escape("a\\b"), "a\\\\b");
|
||||||
// A trailing backslash must not escape the closing quote of the JSON string.
|
// A trailing backslash must not escape the closing quote of the JSON string.
|
||||||
EXPECT_EQ(escape("net\\"), "net\\\\");
|
EXPECT_EQ(escape("net\\"), "net\\\\");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The control characters with short JSON forms use those forms.
|
// The control characters with short JSON forms use those forms.
|
||||||
TEST(CaptivePortalJsonEscape, EscapesShortFormControls) {
|
TEST(JsonEscape, EscapesShortFormControls) {
|
||||||
EXPECT_EQ(escape("\n"), "\\n");
|
EXPECT_EQ(escape("\n"), "\\n");
|
||||||
EXPECT_EQ(escape("\r"), "\\r");
|
EXPECT_EQ(escape("\r"), "\\r");
|
||||||
EXPECT_EQ(escape("\t"), "\\t");
|
EXPECT_EQ(escape("\t"), "\\t");
|
||||||
@@ -49,7 +50,7 @@ TEST(CaptivePortalJsonEscape, EscapesShortFormControls) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex.
|
// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex.
|
||||||
TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) {
|
TEST(JsonEscape, EscapesOtherControlsAsUnicode) {
|
||||||
EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000");
|
EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000");
|
||||||
EXPECT_EQ(escape("\x01"), "\\u0001");
|
EXPECT_EQ(escape("\x01"), "\\u0001");
|
||||||
EXPECT_EQ(escape("\x10"), "\\u0010");
|
EXPECT_EQ(escape("\x10"), "\\u0010");
|
||||||
@@ -59,7 +60,7 @@ TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim.
|
// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim.
|
||||||
TEST(CaptivePortalJsonEscape, PassesThroughUtf8) {
|
TEST(JsonEscape, PassesThroughUtf8) {
|
||||||
// "café" in UTF-8 (é == 0xC3 0xA9).
|
// "café" in UTF-8 (é == 0xC3 0xA9).
|
||||||
EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9");
|
EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9");
|
||||||
// Emoji (📶, 4-byte UTF-8) survives unchanged.
|
// Emoji (📶, 4-byte UTF-8) survives unchanged.
|
||||||
@@ -67,10 +68,10 @@ TEST(CaptivePortalJsonEscape, PassesThroughUtf8) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A mix of special and normal characters is escaped in place without disturbing the rest.
|
// A mix of special and normal characters is escaped in place without disturbing the rest.
|
||||||
TEST(CaptivePortalJsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); }
|
TEST(JsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); }
|
||||||
|
|
||||||
// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly.
|
// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly.
|
||||||
TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) {
|
TEST(JsonEscape, WorstCaseInputFitsExactly) {
|
||||||
constexpr size_t input_len = 8;
|
constexpr size_t input_len = 8;
|
||||||
char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1];
|
char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1];
|
||||||
const std::string input(input_len, '\x01');
|
const std::string input(input_len, '\x01');
|
||||||
@@ -82,7 +83,7 @@ TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) {
|
|||||||
|
|
||||||
// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null
|
// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null
|
||||||
// terminated.
|
// terminated.
|
||||||
TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) {
|
TEST(JsonEscape, DropsEscapeThatWouldNotFit) {
|
||||||
// Room for one \u00XX sequence plus the null terminator, but two are requested.
|
// Room for one \u00XX sequence plus the null terminator, but two are requested.
|
||||||
char buf[JSON_ESCAPE_MAX_EXPANSION + 1];
|
char buf[JSON_ESCAPE_MAX_EXPANSION + 1];
|
||||||
const std::string input(2, '\x01');
|
const std::string input(2, '\x01');
|
||||||
@@ -92,16 +93,16 @@ TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plain characters are truncated at the buffer size, leaving room for the null terminator.
|
// Plain characters are truncated at the buffer size, leaving room for the null terminator.
|
||||||
TEST(CaptivePortalJsonEscape, TruncatesPlainInput) {
|
TEST(JsonEscape, TruncatesPlainInput) {
|
||||||
char buf[5];
|
char buf[5];
|
||||||
const std::string input(20, 'a');
|
const std::string input(20, 'a');
|
||||||
EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa");
|
EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing.
|
// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing.
|
||||||
TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) {
|
TEST(JsonEscape, EmptyBufferIsSafe) {
|
||||||
const std::string input("test");
|
const std::string input("test");
|
||||||
EXPECT_STREQ(json_escape_into_buffer(std::span<char>(), StringRef(input.c_str(), input.size())), "");
|
EXPECT_STREQ(json_escape_into_buffer(std::span<char>(), StringRef(input.c_str(), input.size())), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace esphome::captive_portal::testing
|
} // namespace esphome::testing
|
||||||
Reference in New Issue
Block a user