[api] Enforce the 16383 message ID cap with a generated constant and static_assert

This commit is contained in:
J. Nick Koston
2026-08-19 18:11:21 -05:00
parent 7d03b07b18
commit d701d70659
5 changed files with 53 additions and 12 deletions
+4 -2
View File
@@ -326,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase {
bool is_marked_for_removal() const { return this->flags_.remove; }
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
// Get client API version for feature detection
bool client_supports_api_version(uint16_t major, uint16_t minor) const {
// Get client API version for feature detection.
// Stored versions saturate at 255 (see send_hello_response_), so requesting
// a minimum above that can never match.
bool client_supports_api_version(uint8_t major, uint8_t minor) const {
return this->client_api_version_major_ > major ||
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
}
@@ -5,6 +5,7 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "api_pb2.h"
#include "proto.h"
#include <cstring>
#include <cinttypes>
@@ -252,6 +253,13 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_
*p = static_cast<uint8_t>(value);
}
// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint
// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this
// bound, write_plaintext_header's header_offset would underflow for the first
// message in a batch and the header write would land outside the buffer.
static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING,
"HEADER_PADDING cannot fit the type varint of the largest message ID");
// Write plaintext header into pre-allocated padding before payload.
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
// actual header size for contiguous batch messages).
+4
View File
@@ -9,6 +9,10 @@
namespace esphome::api {
// Upper bound on message IDs, enforced by the code generator: the plaintext
// frame header budgets 2 varint bytes for the type (HEADER_PADDING).
static constexpr uint16_t MAX_MESSAGE_TYPE = 16383;
namespace enums {
enum DisconnectReason : uint32_t {
+20 -9
View File
@@ -475,6 +475,19 @@ TYPE_INFO: dict[int, TypeInfo] = {}
# TYPE_DOUBLE = 1, TYPE_FIXED64 = 6, TYPE_SFIXED64 = 16, TYPE_SINT64 = 18
UNSUPPORTED_TYPES = {1: "double", 6: "fixed64", 16: "sfixed64", 18: "sint64"}
# The plaintext frame header budgets 2 varint bytes for the message type
# (APIPlaintextFrameHelper::HEADER_PADDING), which caps message IDs at 16383.
MAX_MESSAGE_ID = 16383
def validate_message_id(message_id: int, message_name: str) -> None:
"""Reject message IDs whose plaintext type varint would not fit in 2 bytes."""
if message_id > MAX_MESSAGE_ID:
raise ValueError(
f"Message ID {message_id} for {message_name} exceeds the plaintext "
f"2-byte type varint maximum ({MAX_MESSAGE_ID})"
)
def validate_field_type(field_type: int, field_name: str = "") -> None:
"""Validate that the field type is supported by ESPHome API.
@@ -2511,13 +2524,7 @@ def build_message_type(
# Add MESSAGE_TYPE method if this is a service message
if message_id is not None:
# The plaintext frame header budgets 2 varint bytes for the type
# (HEADER_PADDING), which caps message IDs at 16383
if message_id > 16383:
raise ValueError(
f"Message ID {message_id} for {desc.name} exceeds the plaintext "
"2-byte type varint maximum (16383)"
)
validate_message_id(message_id, desc.name)
# Add static constexpr for message type
public_content.append(f"static constexpr uint16_t MESSAGE_TYPE = {message_id};")
@@ -3176,8 +3183,12 @@ def main() -> None:
#include "api_pb2_includes.h"
"""
content += """
namespace esphome::api {
content += f"""
namespace esphome::api {{
// Upper bound on message IDs, enforced by the code generator: the plaintext
// frame header budgets 2 varint bytes for the type (HEADER_PADDING).
static constexpr uint16_t MAX_MESSAGE_TYPE = {MAX_MESSAGE_ID};
"""
@@ -15,7 +15,12 @@ import pytest
sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf"))
from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402
from api_protobuf import ( # noqa: E402
MAX_MESSAGE_ID,
_make_ifdef_line,
get_varint64_ifdef,
validate_message_id,
)
from google.protobuf import descriptor_pb2 # noqa: E402
@@ -91,3 +96,14 @@ def test_make_ifdef_line_conjunction_and_negation() -> None:
assert (
_make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)"
)
def test_message_id_at_maximum_is_accepted() -> None:
# 16383 is the largest ID whose plaintext type varint fits the 2 bytes
# budgeted in HEADER_PADDING.
validate_message_id(MAX_MESSAGE_ID, "MaxMessage")
def test_message_id_above_maximum_is_rejected() -> None:
with pytest.raises(ValueError, match="exceeds the plaintext"):
validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage")