From cfcf9216f3e53bd2aeae6a9291b7670bc75687b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Apr 2026 18:33:04 -1000 Subject: [PATCH] [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 --- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp8266/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 2 +- esphome/components/nrf52/__init__.py | 2 +- esphome/components/number/__init__.py | 2 +- esphome/components/rp2040/__init__.py | 2 +- esphome/components/sensor/__init__.py | 2 +- esphome/config_validation.py | 34 +++++++++++++++++++----- esphome/core/config.py | 10 +++---- esphome/core/entity_helpers.py | 15 ++++++----- 10 files changed, 49 insertions(+), 24 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 54eecb5ad1..f27690c97b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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 diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 76c8b1e870..bef7e36470 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -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, diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 00be620831..656eee6d7b 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -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, diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 566aefb209..5d92a4fa80 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -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( diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 26d2602ba4..1a0dc42407 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -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 = ( diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 628cdf370a..e452780d41 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -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( diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 275c4542fb..550e32c506 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -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 diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c6b67e9f35..cbf81de147 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -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) diff --git a/esphome/core/config.py b/esphome/core/config.py index efc6d62f48..bf210876df 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -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( { diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index fc931c2baa..f09dd013fe 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -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")