From a4611907a9f740cb4f5f43ac6d0fccc281bcaa1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 28 Jul 2026 15:23:01 +0300 Subject: [PATCH] [captive_portal] Re-apply json_escape move lost in release merge (#17906) --- .../captive_portal/captive_portal.cpp | 1 - .../components/captive_portal/json_escape.h | 85 -------------- tests/components/captive_portal/__init__.py | 23 ---- .../captive_portal/json_escape_test.cpp | 107 ------------------ 4 files changed, 216 deletions(-) delete mode 100644 esphome/components/captive_portal/json_escape.h delete mode 100644 tests/components/captive_portal/__init__.py delete mode 100644 tests/components/captive_portal/json_escape_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5716346d07..8094903008 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,7 +6,6 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" -#include "json_escape.h" namespace esphome::captive_portal { diff --git a/esphome/components/captive_portal/json_escape.h b/esphome/components/captive_portal/json_escape.h deleted file mode 100644 index 0b3c71cd74..0000000000 --- a/esphome/components/captive_portal/json_escape.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once -#include -#include -#include - -#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 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(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(c >> 4)); - buf[pos++] = format_hex_char(static_cast(c & 0x0F)); - } else { - if (pos + 1 > limit) - break; - buf[pos++] = static_cast(c); - } - } - buf[pos] = '\0'; - return buf.data(); -} - -} // namespace esphome::captive_portal diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py deleted file mode 100644 index b13c81912c..0000000000 --- a/tests/components/captive_portal/__init__.py +++ /dev/null @@ -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 diff --git a/tests/components/captive_portal/json_escape_test.cpp b/tests/components/captive_portal/json_escape_test.cpp deleted file mode 100644 index 98b5ce4ff7..0000000000 --- a/tests/components/captive_portal/json_escape_test.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include - -#include - -#include "esphome/components/captive_portal/json_escape.h" - -namespace esphome::captive_portal::testing { - -namespace { - -// Large enough that none of the inputs below are ever dropped. -constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1; - -// Escape into a stack buffer and return the result as a string so the expectations stay readable. -std::string escape(const std::string &value) { - char buf[TEST_BUFFER_SIZE]; - return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size())); -} - -} // namespace - -// Plain ASCII with no special characters is passed through unchanged. -TEST(CaptivePortalJsonEscape, PlainStringUnchanged) { - EXPECT_EQ(escape("MyNetwork"), "MyNetwork"); - EXPECT_EQ(escape(""), ""); -} - -// A double quote is escaped so it does not terminate the surrounding JSON string. -TEST(CaptivePortalJsonEscape, EscapesDoubleQuote) { - EXPECT_EQ(escape("a\"b"), "a\\\"b"); - // A double quote followed by other characters stays inside the JSON string. - EXPECT_EQ(escape("\">end"), "\\\">end"); -} - -// A backslash is doubled so it does not start an escape sequence in the output. -TEST(CaptivePortalJsonEscape, EscapesBackslash) { - EXPECT_EQ(escape("a\\b"), "a\\\\b"); - // A trailing backslash must not escape the closing quote of the JSON string. - EXPECT_EQ(escape("net\\"), "net\\\\"); -} - -// The control characters with short JSON forms use those forms. -TEST(CaptivePortalJsonEscape, EscapesShortFormControls) { - EXPECT_EQ(escape("\n"), "\\n"); - EXPECT_EQ(escape("\r"), "\\r"); - EXPECT_EQ(escape("\t"), "\\t"); - EXPECT_EQ(escape("\b"), "\\b"); - EXPECT_EQ(escape("\f"), "\\f"); -} - -// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex. -TEST(CaptivePortalJsonEscape, EscapesOtherControlsAsUnicode) { - EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000"); - EXPECT_EQ(escape("\x01"), "\\u0001"); - EXPECT_EQ(escape("\x10"), "\\u0010"); - EXPECT_EQ(escape("\x1f"), "\\u001f"); - // 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper. - EXPECT_EQ(escape("\x7f"), "\x7f"); -} - -// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim. -TEST(CaptivePortalJsonEscape, PassesThroughUtf8) { - // "café" in UTF-8 (é == 0xC3 0xA9). - EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9"); - // Emoji (📶, 4-byte UTF-8) survives unchanged. - EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6"); -} - -// 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"); } - -// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly. -TEST(CaptivePortalJsonEscape, WorstCaseInputFitsExactly) { - constexpr size_t input_len = 8; - char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1]; - const std::string input(input_len, '\x01'); - std::string expected; - for (size_t i = 0; i < input_len; i++) - expected += "\\u0001"; - EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected); -} - -// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null -// terminated. -TEST(CaptivePortalJsonEscape, DropsEscapeThatWouldNotFit) { - // Room for one \u00XX sequence plus the null terminator, but two are requested. - char buf[JSON_ESCAPE_MAX_EXPANSION + 1]; - const std::string input(2, '\x01'); - const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())); - EXPECT_EQ(result, "\\u0001"); - EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0'); -} - -// Plain characters are truncated at the buffer size, leaving room for the null terminator. -TEST(CaptivePortalJsonEscape, TruncatesPlainInput) { - char buf[5]; - const std::string input(20, 'a'); - 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. -TEST(CaptivePortalJsonEscape, EmptyBufferIsSafe) { - const std::string input("test"); - EXPECT_STREQ(json_escape_into_buffer(std::span(), StringRef(input.c_str(), input.size())), ""); -} - -} // namespace esphome::captive_portal::testing