mirror of
https://github.com/esphome/esphome.git
synced 2026-09-01 10:36:01 +00:00
[core] Add cv.ByteLength validator, switch all proto-backed length checks to byte length
Add cv.ByteLength() validator that checks UTF-8 byte length instead of character count. This ensures multibyte characters don't cause the encoded string to exceed the proto max_data_length limit. Switch all length validators that feed into proto fields with max_data_length annotations to use byte length: - Entity names, friendly names, area names, device names - Icons, device classes, units of measurement - Board names, project name/version
This commit is contained in:
@@ -1405,7 +1405,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_BOARD): cv.All(
|
||||
cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH)
|
||||
cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(CONF_CPU_FREQUENCY): cv.one_of(
|
||||
*FULL_CPU_FREQUENCIES, upper=True
|
||||
|
||||
@@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_BOARD): cv.All(
|
||||
cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH)
|
||||
cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA,
|
||||
cv.Optional(CONF_RESTORE_FROM_FLASH, default=False): cv.boolean,
|
||||
|
||||
@@ -268,7 +268,7 @@ BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(LTComponent),
|
||||
cv.Required(CONF_BOARD): cv.All(
|
||||
cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH)
|
||||
cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(CONF_FAMILY): cv.one_of(*FAMILIES, upper=True),
|
||||
cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA,
|
||||
|
||||
@@ -147,7 +147,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_BOARD): cv.All(
|
||||
cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH)
|
||||
cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True),
|
||||
cv.Optional(CONF_DFU): cv.Schema(
|
||||
|
||||
@@ -190,7 +190,7 @@ validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
|
||||
validate_unit_of_measurement = cv.All(
|
||||
cv.string_strict,
|
||||
# Keep in sync with max_data_length in api.proto
|
||||
cv.Length(max=UNIT_OF_MEASUREMENT_MAX_LENGTH),
|
||||
cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH),
|
||||
)
|
||||
|
||||
_NUMBER_SCHEMA = (
|
||||
|
||||
@@ -170,7 +170,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_BOARD): cv.All(
|
||||
cv.string_strict, cv.Length(max=BOARD_MAX_LENGTH)
|
||||
cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA,
|
||||
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All(
|
||||
|
||||
@@ -294,7 +294,7 @@ RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter)
|
||||
validate_unit_of_measurement = cv.All(
|
||||
cv.string_strict,
|
||||
# Keep in sync with max_data_length in api.proto
|
||||
cv.Length(max=UNIT_OF_MEASUREMENT_MAX_LENGTH),
|
||||
cv.ByteLength(max=UNIT_OF_MEASUREMENT_MAX_LENGTH),
|
||||
)
|
||||
validate_accuracy_decimals = cv.int_
|
||||
validate_icon = cv.icon
|
||||
|
||||
@@ -130,6 +130,26 @@ RequiredFieldInvalid = vol.RequiredFieldInvalid
|
||||
# the rest of the error path is relative to the root config path
|
||||
ROOT_CONFIG_PATH = object()
|
||||
|
||||
|
||||
def ByteLength(*, max: int):
|
||||
"""Validate that the UTF-8 byte length of a string does not exceed max.
|
||||
|
||||
Use instead of Length() when the limit must apply to encoded bytes,
|
||||
not characters (e.g. for protobuf length-varint constraints).
|
||||
"""
|
||||
|
||||
def validator(value):
|
||||
byte_len = len(str(value).encode("utf-8"))
|
||||
if byte_len > max:
|
||||
raise Invalid(
|
||||
f"String is too long ({byte_len} bytes, max {max}). "
|
||||
f"Multibyte characters count as multiple bytes."
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
RESERVED_IDS = [
|
||||
# C++ keywords https://en.cppreference.com/w/cpp/keyword
|
||||
"alarm",
|
||||
@@ -411,9 +431,10 @@ def icon(value):
|
||||
raise Invalid(
|
||||
'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"'
|
||||
)
|
||||
if len(value) > ICON_MAX_LENGTH:
|
||||
byte_len = len(value.encode("utf-8"))
|
||||
if byte_len > ICON_MAX_LENGTH:
|
||||
raise Invalid(
|
||||
f"Icon string is too long ({len(value)} chars, max {ICON_MAX_LENGTH}). "
|
||||
f"Icon string is too long ({byte_len} bytes, max {ICON_MAX_LENGTH}). "
|
||||
"Icons are stored in PROGMEM with a 64-byte buffer limit."
|
||||
)
|
||||
return value
|
||||
@@ -2067,11 +2088,12 @@ def _validate_entity_name(value):
|
||||
"Name cannot be None when esphome->friendly_name is not set!"
|
||||
)(value)
|
||||
if value is not None:
|
||||
# Validate length for web server URL compatibility
|
||||
if len(value) > NAME_MAX_LENGTH:
|
||||
# Validate byte length for web server URL and proto encoding compatibility
|
||||
byte_len = len(value.encode("utf-8"))
|
||||
if byte_len > NAME_MAX_LENGTH:
|
||||
raise Invalid(
|
||||
f"Name is too long ({len(value)} chars). "
|
||||
f"Maximum length is {NAME_MAX_LENGTH} characters."
|
||||
f"Name is too long ({byte_len} bytes). "
|
||||
f"Maximum length is {NAME_MAX_LENGTH} bytes."
|
||||
)
|
||||
# Validate no '/' in name for web server URL compatibility
|
||||
value = _validate_no_slash(value)
|
||||
|
||||
@@ -246,7 +246,7 @@ AREA_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(Area),
|
||||
cv.Required(CONF_NAME): cv.All(
|
||||
cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN)
|
||||
cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN)
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -255,7 +255,7 @@ DEVICE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(Device),
|
||||
cv.Required(CONF_NAME): cv.All(
|
||||
cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN)
|
||||
cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN)
|
||||
),
|
||||
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
|
||||
}
|
||||
@@ -272,7 +272,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Required(CONF_NAME): cv.valid_name,
|
||||
# Keep max=120 in sync with OBJECT_ID_MAX_LEN in esphome/core/entity_base.h
|
||||
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(
|
||||
cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN)
|
||||
cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN)
|
||||
),
|
||||
cv.Optional(CONF_AREA): validate_area_config,
|
||||
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
|
||||
@@ -314,10 +314,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Required(CONF_NAME): cv.All(
|
||||
cv.string_strict,
|
||||
valid_project_name,
|
||||
cv.Length(max=PROJECT_MAX_LENGTH),
|
||||
cv.ByteLength(max=PROJECT_MAX_LENGTH),
|
||||
),
|
||||
cv.Required(CONF_VERSION): cv.All(
|
||||
cv.string_strict, cv.Length(max=PROJECT_MAX_LENGTH)
|
||||
cv.string_strict, cv.ByteLength(max=PROJECT_MAX_LENGTH)
|
||||
),
|
||||
cv.Optional(CONF_ON_UPDATE): automation.validate_automation(
|
||||
{
|
||||
|
||||
@@ -193,9 +193,10 @@ def _register_string(
|
||||
|
||||
def register_device_class(value: str) -> int:
|
||||
"""Register a device_class string and return its 1-based index."""
|
||||
if value and len(value) > DEVICE_CLASS_MAX_LENGTH:
|
||||
byte_len = len(value.encode("utf-8")) if value else 0
|
||||
if byte_len > DEVICE_CLASS_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"Device class string too long ({len(value)} chars, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'"
|
||||
f"Device class string too long ({byte_len} bytes, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'"
|
||||
)
|
||||
return _register_string(
|
||||
value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class"
|
||||
@@ -204,9 +205,10 @@ def register_device_class(value: str) -> int:
|
||||
|
||||
def register_unit_of_measurement(value: str) -> int:
|
||||
"""Register a unit_of_measurement string and return its 1-based index."""
|
||||
if value and len(value) > UNIT_OF_MEASUREMENT_MAX_LENGTH:
|
||||
byte_len = len(value.encode("utf-8")) if value else 0
|
||||
if byte_len > UNIT_OF_MEASUREMENT_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"Unit of measurement string too long ({len(value)} chars, "
|
||||
f"Unit of measurement string too long ({byte_len} bytes, "
|
||||
f"max {UNIT_OF_MEASUREMENT_MAX_LENGTH}): '{value}'"
|
||||
)
|
||||
return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement")
|
||||
@@ -214,9 +216,10 @@ def register_unit_of_measurement(value: str) -> int:
|
||||
|
||||
def register_icon(value: str) -> int:
|
||||
"""Register an icon string and return its 1-based index."""
|
||||
if value and len(value) > ICON_MAX_LENGTH:
|
||||
byte_len = len(value.encode("utf-8")) if value else 0
|
||||
if byte_len > ICON_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"Icon string too long ({len(value)} chars, max {ICON_MAX_LENGTH}): '{value}'"
|
||||
f"Icon string too long ({byte_len} bytes, max {ICON_MAX_LENGTH}): '{value}'"
|
||||
)
|
||||
return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user