Compare commits

...
Author SHA1 Message Date
J. Nick Koston ebdbf61808 Merge remote-tracking branch 'origin/dev' into store-yaml-firmware 2026-10-26 05:51:45 -10:00
pre-commit-ci-lite[bot] 9e865fe8f7 apply automatic formatting fixes 2026-10-26 15:06:02 +00:00
J. Nick Koston 877124e29a [store_yaml] Declare the YAML blob in the api header and drop store_yaml.h 2026-10-26 05:03:18 -10:00
J. Nick Koston 953708ae1b Merge branch 'dev' into store-yaml-firmware 2026-10-26 04:56:40 -10:00
J. Nick Koston 489b2fe7f6 [store_yaml] Redact the modem pin_code 2026-10-26 04:54:32 -10:00
J. Nick Koston 7f0c4cfde1 Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware
# Conflicts:
#	tests/integration/raw_api_client.py
2026-10-26 03:59:56 -10:00
J. Nick Koston c9807f3997 Merge remote-tracking branch 'origin/dev' into store-yaml-core 2026-10-26 03:59:07 -10:00
J. Nick Koston 34cc68e944 [store_yaml] Put each regex under its own comment 2026-10-22 15:51:30 -10:00
J. Nick Koston fe57ced916 [store_yaml] Match an inline lowercase bind key to its uppercased value 2026-10-22 15:34:14 -10:00
J. Nick Koston 6c81b51baf [store_yaml] Cover bind keys: name them as credentials and match a lowercase secret 2026-10-22 15:10:50 -10:00
J. Nick Koston 2cdc3f4b3d [store_yaml] Don't take include file names or one-segment directory fragments for credentials 2026-10-22 14:18:11 -10:00
J. Nick Koston 24605205f8 [store_yaml] Use the released GetYaml messages in the integration tests 2026-10-22 14:13:37 -10:00
J. Nick Koston 9ef5271113 Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware 2026-10-22 14:12:33 -10:00
J. Nick Koston 80bf95f162 Merge remote-tracking branch 'origin/dev' into store-yaml-core 2026-10-22 14:12:04 -10:00
J. Nick Koston e44a16bc4d Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware 2026-10-22 14:07:46 -10:00
J. Nick Koston 470d163f91 [core] Assert the POSIX named_path result only off Windows 2026-10-22 14:07:38 -10:00
J. Nick Koston 887af66470 Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware 2026-10-22 14:03:10 -10:00
J. Nick Koston 3a7c3ec9a7 [core] Make the Windows named_path test tell the two resolutions apart 2026-10-22 14:02:56 -10:00
J. Nick Koston 4352ea190e Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware 2026-10-22 13:55:16 -10:00
J. Nick Koston 6a142cafca [core] Document what aliases and links leave out 2026-10-22 13:55:04 -10:00
J. Nick Koston 84c99e25cb Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware 2026-10-22 13:48:26 -10:00
J. Nick Koston 2b2b55bde7 [core] Keep a literal from taking over its container's YAML alias 2026-10-22 13:48:11 -10:00
J. Nick Koston 6996c9191f Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware 2026-10-22 13:45:44 -10:00
J. Nick Koston e05dbbc2f5 [core] One discovery result shape on every path; pin the sensitive lambda dump 2026-10-22 13:45:22 -10:00
J. Nick Koston 5a5c6f5c0d Merge remote-tracking branch 'origin/store-yaml-core' into store-yaml-firmware
# Conflicts:
#	tests/unit_tests/test_yaml_util.py
2026-10-22 13:39:26 -10:00
J. Nick Koston 2547475bbd [core] Add the YAML loader helpers store_yaml builds on 2026-10-22 13:38:26 -10:00
J. Nick Koston 7898d04ad2 [store_yaml] Word the unverified-credential error for every case it covers 2026-10-22 13:27:14 -10:00
J. Nick Koston 8f046887d7 [store_yaml] Treat every cv.sensitive key name as a credential, also when a merge or override drops it 2026-10-22 13:22:07 -10:00
J. Nick Koston a702e8f362 [store_yaml] Confirm the send buffer pin before stalling in the backpressure test 2026-10-22 13:17:57 -10:00
J. Nick Koston 1de9fc9c3b [store_yaml] Test multi-chunk recovery under backpressure; read sources as UTF-8 in the guard test 2026-10-22 13:10:17 -10:00
J. Nick Koston 632be43d54 [store_yaml] Name the longest secret first in package descriptions 2026-10-22 13:01:11 -10:00
J. Nick Koston 51332a101a [store_yaml] Name every cv.sensitive key for removed branches and guard the list with a test 2026-10-22 12:58:55 -10:00
J. Nick Koston a4f93bba95 [store_yaml] Name secrets inside package URLs, follow id removals and package-scoped removals, tighten tests 2026-10-22 12:47:07 -10:00
J. Nick Koston 871035de32 [store_yaml] Name credentials in removed branches, limit the package password rule, skip disabled templated includes 2026-10-22 12:34:06 -10:00
J. Nick Koston c73fb52ec2 [store_yaml] Reject only real include tags in a secrets file; cover the lambda branch of cv.sensitive 2026-10-22 12:23:06 -10:00
J. Nick Koston 1eb1cc73da [api] Drop GetYamlResponse.encoding; the blob is always a zstd frame 2026-10-22 12:14:44 -10:00
J. Nick Koston c0b33e26fb [store_yaml] One rule for scalars holding a sensitive value, fold the alias tracing, drop track_yaml_loads 2026-10-22 12:09:29 -10:00
J. Nick Koston bbf3b622f8 [store_yaml] Fail closed on a secret inside a larger value and on a secrets file that includes others 2026-10-22 12:02:47 -10:00
J. Nick Koston 1b303c9686 [store_yaml] Assert the captured package's own include on both platforms 2026-10-22 08:32:01 -10:00
J. Nick Koston 5b6566329d [store_yaml] Swap a quoted integer credential like an unquoted one 2026-10-22 08:22:00 -10:00
J. Nick Koston 68a5925ff9 [store_yaml] Follow the loader's lexical .. on Windows and compare verbatim bytes in the alias test 2026-10-22 08:19:29 -10:00
J. Nick Koston 9fd7fd266b Merge remote-tracking branch 'origin/dev' into store-yaml-firmware
# Conflicts:
#	tests/integration/raw_api_client.py
2026-10-22 08:09:17 -10:00
J. Nick Koston 0b04acfe61 [store_yaml] Skip disabled includes and include cycles when tracing substitution scopes 2026-10-22 08:06:58 -10:00
J. Nick Koston 345f77a671 [store_yaml] Read substitutions defined in inline packages and keep lambdas without literals 2026-10-22 07:08:55 -10:00
J. Nick Koston 1838d53147 [store_yaml] Catch lambda credentials, resolve substitution aliases, and keep disabled includes optional
cv.sensitive now marks a lambda too, so a credential set by one fails
closed instead of shipping in clear. A substitution that aliases another is
followed to its definitions, with a cycle guard. An integer credential
reused through a substitution is swapped at the definition as well. A
missing include behind a static condition: false no longer aborts
discovery, and a package ref from !secret is named, not printed.
2026-10-22 01:18:28 -10:00
J. Nick Koston 18d3a50bcd [store_yaml] Trim the redaction pass and its tests again
The tree pass is a closure of the redaction, collecting includes and
removals as it goes; include scopes propagate each invocation once; the
error paths share one table; the test tables register the secrets file as
discovery would and drop rows that repeat a branch. The symlink note now
measures from the same directory discovery used, so a config reached through
a symlinked directory names its own links.
2026-10-22 01:13:31 -10:00
J. Nick Koston 8c10399320 [store_yaml] Check included files at their include site, swap integer credentials only at their keys, track symlinks below the config directory
An included file's paths are checked at the config location its include
site gives them, with every packages prefix merged away, so a key: option in
an included list file is no longer taken for api.encryption.key. An integer
credential is swapped by the tree pass at its own keys instead of by the
dumper everywhere, so an unrelated 0 stays 0. Directory symlink tracking
skips the config directory and its ancestors.
2026-10-22 00:56:17 -10:00
J. Nick Koston 9d6d996333 [store_yaml] Put a file's own defaults above the caller's chain in nested include scopes
The loader resolves an include's vars, then the file's defaults, then the
caller's chain; the scope analysis now builds the same chain per invocation,
so a literal default in a nested file at a credential location fails closed.
2026-10-22 00:50:37 -10:00
J. Nick Koston 99f6daf607 [store_yaml] Cut comments and docstrings to the house length 2026-10-22 00:48:54 -10:00
J. Nick Koston 75d7d263cf [store_yaml] Inherit caller variables in nested includes, keep secret vars mappings, scan shared texts
A nested include sees the variables its caller received, as the substitution
pass does, with each invocation kept apart. An include whose whole vars
mapping is a !secret is recorded before folding, so scope analysis still
sees its names and treats their values as secret. A text that is an integer
credential in one place and a string in another keeps the substring scan.
2026-10-22 00:45:08 -10:00
J. Nick Koston 6af39d459c [store_yaml] Check removed subtrees, keep zero credentials, and follow directory symlinks
Under a subtree that !remove drops before validation, any key that is a
credential elsewhere in the config is a credential location. An integer
credential of 0 is redacted like any other value; only an empty string is
skipped. Every !include is pointed at its target's captured name, so an
include through a directory symlink recovers, and discovery records the
symlinked directories so the verbatim mode's note says how to recreate them.
2026-10-22 00:36:29 -10:00
J. Nick Koston e5829c7f00 [store_yaml] Trim the component and its tests
Fail-closed and redaction behaviour are unchanged. The redaction tests share
one helper and two parametrized tables, the envelope packer and decoder use
struct directly, the three error paths share one message, the include walk
happens while preparing each tree, the credential check keeps git package
keys out of the component, the git URL masker stays private with only its
pattern exported, the API key check reuses noise.static_encryption_key, and
the integration client reads unknown protobuf fields with the library's own
UnknownFieldSet.
2026-10-22 00:26:02 -10:00
J. Nick Koston 0130af44b5 [store_yaml] Track credential paths by location, scan keys, and reparse files by name
Credential locations come from every path validation marked sensitive, not
from the value-deduplicated map, so domains sharing a password and empty final
values keep theirs; matching allows segments validation inserted, so an action
variable named key is no longer taken for the encryption key. Mapping keys are
scanned for embedded credentials, except the key a value is sensitive at.
Include scopes follow the loader's precedence per include. Files are reparsed
and includes rebased by a path whose directory is resolved but whose name is
kept, so a symlinked file keeps its name while .. follows the link.
2026-10-22 00:05:34 -10:00
J. Nick Koston e6eb127512 [store_yaml] Match validated path shapes and verify references in each include's scope
A credential in an inline package or regrouped by validation (wifi.password
to wifi.networks.password) is still recognised as a credential location.
Substitution references are verified against every definition the file can
see: its include vars, its defaults and the top-level substitutions. Container
secrets inside include vars are folded too, and every URL in a scalar is
checked. The substitution and URL userinfo patterns come from
esphome.expression and esphome.git, the git package keys from packages, and
one walker prepares each tree.
2026-10-21 23:24:59 -10:00
J. Nick Koston cb262fdc64 [store_yaml] Emit every alias of a file and keep credentials out of error paths
Discovery records each name a file is reached by, so two symlinks to one
package both come back in the recovery data. A sensitive value used as a
mapping key is reported by its config path, never by the key itself, and
paths in every message mask segments that are credentials.
2026-10-21 23:09:04 -10:00
J. Nick Koston 12099335ea [store_yaml] Trace substituted credentials to secrets and keep container secrets as references
A substitution under a credential path or inside URL userinfo is followed to
its definition in the captured files, which must itself be a secret or a
verified sensitive value; anything else fails closed. A mapping or list loaded
through !secret is written back as the reference instead of its contents.
2026-10-21 23:04:22 -10:00
J. Nick Koston ecaf8518e6 [store_yaml] Match the quoted skeleton in the recovery integration test 2026-10-21 22:50:26 -10:00
J. Nick Koston 8f39754dd5 [store_yaml] Fail closed on unverifiable credentials and keep more of the source structure
Redaction now rejects a value under a credential path that the validated
config never saw (overridden by a later package, or a git package password
stripped before validation) and a URL carrying credentials, unless it comes
from !secret; include conditions are scanned like vars. Remote source
descriptions mask URL userinfo or name the secret. The secrets skeleton is
written with plain PyYAML so keys like "true" stay strings. Files reached
through a symlink are laid out under the name the YAML used, a document that
is one !include keeps its vars, includes pulled in by !include_dir_* are
rebased to the file they are dumped in, and !literal survives the dump.
2026-10-21 22:48:50 -10:00
J. Nick Koston 81d042dc2c [store_yaml] Drop missing secrets probes in discovery, not in the loader
device-builder tracks the absent secrets.yaml a package's !secret probes so
creating it later triggers a rebuild, so the loader keeps reporting every
path it tries. discover_user_yaml_files now skips paths that do not exist.
2026-10-21 21:50:09 -10:00
J. Nick Koston 1a29eeb52d [store_yaml] Only track YAML files the loader actually opened
A !secret in a package probes a secrets.yaml next to that file before
falling back to the entry's directory. The load listeners fired before the
open, so the missing probe was tracked and include_secrets: true failed the
build trying to read it.
2026-10-21 21:43:32 -10:00
J. Nick Koston e23cd53179 Merge branch 'dev' into store-yaml-firmware 2026-10-21 21:39:47 -10:00
pre-commit-ci-lite[bot] 0bd3738430 apply automatic formatting fixes 2026-10-22 04:52:30 +00:00
J. Nick Koston e558e8416e Merge remote-tracking branch 'origin/dev' into store-yaml-firmware
# Conflicts:
#	tests/integration/raw_api_client.py
2026-10-21 18:50:59 -10:00
J. Nick Koston 2f916194af Merge branch 'dev' into store-yaml-firmware 2026-10-13 20:54:22 -10:00
J. Nick Koston dbb74e6746 Redact sensitive integer values in store_yaml 2026-10-13 20:15:59 -10:00
J. Nick Koston 3adf71ce21 Merge remote-tracking branch 'origin/dev' into store-yaml-firmware 2026-10-13 13:33:29 -10:00
J. Nick Koston 53e7d78156 Simplify store_yaml, trim comments, dedup tests, add DOMAIN 2026-10-12 13:36:02 -10:00
J. Nick Koston 76a086d999 Merge remote-tracking branch 'origin/dev' into store-yaml-firmware 2026-10-12 13:28:35 -10:00
J. Nick Koston 71e55e300c Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-12 07:56:25 -10:00
J. Nick Koston f72e256dd3 Merge remote-tracking branch 'origin/dev' into store-yaml-helpers-core
# Conflicts:
#	esphome/yaml_util.py
2026-10-12 07:56:22 -10:00
J. Nick Koston 4e3f72e360 Merge branch 'store-yaml-helpers-core' into store-yaml-firmware
# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_connection.cpp
#	esphome/components/api/api_pb2.cpp
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_dump.cpp
#	esphome/components/api/api_pb2_service.cpp
#	esphome/components/api/api_pb2_service.h
2026-10-09 07:29:32 -10:00
J. Nick Koston 94630282ee Merge branch 'store-yaml-secret-helpers' into store-yaml-helpers-core
# Conflicts:
#	esphome/components/packages/__init__.py
2026-10-09 07:28:04 -10:00
J. Nick Koston 069d0dd03d Merge remote-tracking branch 'origin/dev' into store-yaml-secret-helpers 2026-10-09 07:27:35 -10:00
J. Nick Koston 69f004793f Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-08 07:29:57 -10:00
J. Nick Koston 21183e454d Merge branch 'store-yaml-secret-helpers' into store-yaml-helpers-core 2026-10-08 07:29:57 -10:00
J. Nick Koston 2bc1ced27a [core] Stop the secret reference scan at flow collection punctuation 2026-10-08 07:29:56 -10:00
J. Nick Koston a83a0b88f9 Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-08 07:24:43 -10:00
J. Nick Koston d3410103e2 Merge branch 'store-yaml-secret-helpers' into store-yaml-helpers-core 2026-10-08 07:24:43 -10:00
J. Nick Koston 5fb9ac56f9 [core] Track loaded secret names separately and pass nested emitted names outward 2026-10-08 07:24:43 -10:00
J. Nick Koston 6763b5627d [store_yaml] Trim comments and docstrings 2026-10-08 06:36:46 -10:00
J. Nick Koston 2bcaf016b3 [core] Trim the discovery helper comments 2026-10-08 06:36:00 -10:00
J. Nick Koston 62c9015248 Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-08 06:36:00 -10:00
J. Nick Koston 55d5d4c12c Merge branch 'store-yaml-secret-helpers' into store-yaml-helpers-core 2026-10-08 06:35:52 -10:00
J. Nick Koston 0cfede51c5 [core] Trim the secret helper comments 2026-10-08 06:35:35 -10:00
J. Nick Koston 73dac9e655 Merge remote-tracking branch 'origin/store-yaml-helpers-core' into store-yaml-firmware 2026-10-08 06:33:39 -10:00
J. Nick Koston c5bb88d866 [core] Match the bad candidate by file name so the test passes on Windows 2026-10-08 11:08:44 -04:00
J. Nick Koston 6d41a7b69a [core] Warn when a secret swap drops a wrapper tag and test the bundle scan warning 2026-10-08 11:08:44 -04:00
J. Nick Koston a231bce3c6 [core] Log discovery load failures once and test package fetch order 2026-10-08 11:08:44 -04:00
J. Nick Koston 3d42a51053 [core] Add YAML secret reference and registration helpers 2026-10-08 11:08:44 -04:00
J. Nick Koston 9f775b95f9 [core] Report incomplete YAML discovery and record remote package sources 2026-10-08 11:08:44 -04:00
J. Nick Koston 1fc0dbbf09 Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-07 23:03:30 -10:00
J. Nick Koston 038feaa95a [core] Match the bad candidate by file name so the test passes on Windows 2026-10-07 23:03:28 -10:00
J. Nick Koston f3588da2e6 [store_yaml] Describe what the component embeds in its isolation reason 2026-10-07 22:55:26 -10:00
J. Nick Koston 14d85dd639 Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-07 22:55:01 -10:00
J. Nick Koston e1b5804036 [core] Log discovery load failures once and test package fetch order 2026-10-07 22:54:47 -10:00
J. Nick Koston 8d9e9de8eb Merge branch 'store-yaml-secret-helpers' into store-yaml-helpers-core 2026-10-07 22:52:21 -10:00
J. Nick Koston 66374467bf [core] Warn when a secret swap drops a wrapper tag and test the bundle scan warning 2026-10-07 22:51:55 -10:00
J. Nick Koston 5385a35b56 Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-07 22:40:38 -10:00
J. Nick Koston 3e812922f3 [core] Report incomplete YAML discovery and record remote package sources 2026-10-07 22:40:31 -10:00
J. Nick Koston 8505074859 [core] Add YAML secret reference and registration helpers 2026-10-07 22:40:09 -10:00
J. Nick Koston 95dac2c3dc [core] Move zstd_module from the api wizard to esphome.helpers 2026-10-07 22:38:42 -10:00
J. Nick Koston 7288aa478d Merge branch 'store-yaml-helpers-core' into store-yaml-firmware 2026-10-07 22:22:50 -10:00
J. Nick Koston e0ae16daaf Merge remote-tracking branch 'origin/dev' into store-yaml-helpers-core 2026-10-07 22:22:14 -10:00
J. Nick Koston dafa2c2729 Merge remote-tracking branch 'origin/store-yaml-helpers-core' into store-yaml-firmware-wt 2026-10-07 13:34:24 -10:00
J. Nick Koston b192a1beff Restore the outer secret collector on exit and bound decode_fields slices
A nested secret_values_registered context hands the collector back to the
outer one. decode_fields asserts on truncated length-delimited and fixed32
fields, and read_frame documents that it returns the oldest unconsumed frame.
2026-10-07 13:34:11 -10:00
J. Nick Koston 3b79d6fcaa [store_yaml] Emit the blob as a flash symbol instead of a component
Codegen emits STORE_YAML_DATA and STORE_YAML_DATA_SIZE the way the wizard
does, so the component, its global pointer and the wait for its setup are
gone and the API reads the blob directly. Record whether a sensitive value
came from !secret once, merge the unencrypted warnings, compute the remote
package list once and trim comments.
2026-10-07 13:32:02 -10:00
J. Nick Koston 56308ebfae Merge remote-tracking branch 'origin/api-progmem-bytes-option' into store-yaml-firmware-wt 2026-10-07 13:27:53 -10:00
J. Nick Koston bd606eb7c5 Merge remote-tracking branch 'origin/store-yaml-helpers-core' into store-yaml-firmware-wt 2026-10-07 13:27:53 -10:00
J. Nick Koston c8fd00e8c4 Simplify the include walk, secret collector, package sources and raw client
force_load_include_files creates its result once and the recursive walker
returns nothing, the never nested secret collector is a single set, package
sources are an ordered dict returned as a tuple, and the raw api client
shares one receive step.
2026-10-07 13:27:39 -10:00
J. Nick Koston fbe43dbca7 Share the dump byte limit and read the progmem option directly 2026-10-07 13:25:28 -10:00
J. Nick Koston d7b5a8e9c8 [store_yaml] Use the shared zstd, progmem and raw api client helpers
GetYamlResponse marks its data (progmem), so every platform streams the
blob straight from flash and the ESP8266 bounce buffer is gone. The zstd
import and the integration test's plaintext client use the shared helpers.
2026-10-07 13:21:05 -10:00
J. Nick Koston bbf1390fea Merge remote-tracking branch 'origin/store-yaml-helpers-core' into store-yaml-firmware-wt 2026-10-07 13:20:01 -10:00
J. Nick Koston 4329314996 Keep real secrets on context exit, log parse failures once, return a copy of package sources
secret_values_registered only removes mappings it still owns, so a real
!secret loaded meanwhile is not dropped. Discovery no longer logs a parse
failure itself, as bundle reports it with the consequence. Note in the
docstring that a wrapper equal to a secret loses its tag.
2026-10-07 13:19:45 -10:00
J. Nick Koston a2116afd97 Merge remote-tracking branch 'origin/api-progmem-bytes-option' into store-yaml-firmware-wt 2026-10-07 13:17:23 -10:00
J. Nick Koston b261578dd3 Merge remote-tracking branch 'origin/store-yaml-helpers-core' into store-yaml-firmware-wt
# Conflicts:
#	requirements.txt
2026-10-07 13:17:23 -10:00
J. Nick Koston c7073eb1ad Keep payloads in the raw api test client
read_frame() returns the next payload of a message type and decode_fields()
splits a payload by field number, for messages the installed aioesphomeapi
does not know yet.
2026-10-07 13:17:00 -10:00
J. Nick Koston bb305bea4d [api] Add a progmem option for bytes fields kept in flash
The generated encode and dump of a pointer_to_buffer bytes field marked
(progmem) copy the data with progmem_memcpy, so ESP8266 can send it from
flash. The device wizard uses it in place of its hand written encoder.
2026-10-07 13:14:52 -10:00
J. Nick Koston 5884ca4d32 [core] Add YAML discovery, secret and zstd helpers
Report unresolved includes and load errors from include discovery, share
the !secret reference scan between bundle and other callers, let callers
register extra secret values for a dump and see which names it emitted,
swap secrets in the !extend, !remove and scalar !include representers,
record the remote sources packages fetch, and move zstd_module out of the
api wizard into esphome.helpers.
2026-10-07 13:06:40 -10:00
J. Nick Koston 6cca15181d Merge remote-tracking branch 'origin/dev' into store-yaml-firmware-wt
# Conflicts:
#	CODEOWNERS
#	esphome/components/api/api.proto
#	esphome/components/api/api_connection.cpp
#	esphome/components/api/api_pb2.cpp
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_dump.cpp
#	esphome/components/api/api_pb2_service.cpp
#	esphome/yaml_util.py
2026-10-07 13:03:08 -10:00
J. Nick Koston da6a016291 Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-17 08:49:32 -05:00
J. Nick Koston 2f3b573752 [store_yaml] Report store_yaml support in DeviceCapabilitiesResponse instead of DeviceInfoResponse 2026-09-14 15:29:10 -05:00
J. Nick Koston e47133171f Merge remote-tracking branch 'origin/dev' into store-yaml-firmware
# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_service.cpp
#	esphome/yaml_util.py
#	tests/integration/conftest.py
#	tests/unit_tests/test_yaml_util.py
2026-09-14 11:07:43 -05:00
J. Nick Koston 5eafd9d2f3 [store_yaml] Cover scalar include secret swap, document secret registry ordering 2026-07-26 10:32:31 -10:00
J. Nick Koston 180414dab4 [store_yaml] Swap secrets in wrapper representers, EsphomeError on cross-anchor paths, branched warning 2026-07-26 08:57:27 -10:00
J. Nick Koston b20e2e0448 [store_yaml] Scan lambda and include payloads, warn on secret overlaps, explicit key gate 2026-07-26 08:14:44 -10:00
J. Nick Koston dbfdd591e8 [store_yaml] Simplify: emission report, unified walker, single scan pass, plain make_unique 2026-07-25 17:25:50 -10:00
J. Nick Koston 21ddd8270f [store_yaml] Scan tree scalars for leaks, guard key swaps, boot race, OOM check 2026-07-25 17:08:20 -10:00
J. Nick Koston 49898359be [store_yaml] Simplify: packages records remote sources, single tree walker, dict-style test packages 2026-07-25 16:46:04 -10:00
J. Nick Koston 8e8aac8fcb [store_yaml] Address review: chunking, re-request no-op, capture gaps, leak scan, tests 2026-07-25 16:22:37 -10:00
J. Nick Koston 66d3816e65 Merge remote-tracking branch 'origin/dev' into store-yaml-firmware-work
# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_pb2.cpp
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_dump.cpp
#	esphome/yaml_util.py
2026-07-25 15:34:25 -10:00
J. Nick Koston df01cb1029 Merge branch 'dev' into store-yaml-firmware 2026-07-03 21:34:47 -05:00
J. Nick Koston 9080082b45 Trigger re-review 2026-07-03 21:33:00 -05:00
J. Nick Koston 4dbf15ac95 [store_yaml] Record uncaptured includes as an envelope note in both modes 2026-07-03 21:20:53 -05:00
J. Nick Koston 6fdbdcbffe Trigger re-review 2026-07-03 21:10:55 -05:00
J. Nick Koston f00efea65c [store_yaml] Fix stale _gather_files return annotation 2026-07-03 21:01:21 -05:00
J. Nick Koston c625c6b795 [store_yaml] Address review: retry terminal frame, fail on unredactable secrets, note uncaptured includes 2026-07-03 20:57:54 -05:00
J. Nick Koston 8f2c09e3c8 [store_yaml] Simplify redaction pipeline, fix Windows newline test failure 2026-07-03 20:40:14 -05:00
J. Nick Koston a25b7a806b [store_yaml] Recover secrets as !secret references using cv.sensitive 2026-07-03 20:31:32 -05:00
J. Nick Koston bd297f08a8 [store_yaml] Simplify: lazy YAML discovery in to_code, zero-copy streaming, shared envelope decoder 2026-07-03 19:38:41 -05:00
J. Nick Koston e4bb7c93a2 [store_yaml] Add coverage for gated YAML discovery in load_config 2026-07-03 19:23:07 -05:00
J. Nick Koston f3ac60b45c [store_yaml] Gate YAML discovery on component presence, fail on unreadable files, warn on unresolved includes 2026-07-03 19:17:04 -05:00
J. Nick Koston d6019ca2c0 Merge branch 'dev' into store-yaml-firmware 2026-07-03 19:04:12 -05:00
J. Nick Koston 5f155c90b7 Merge branch 'dev' into store-yaml-firmware 2026-06-09 20:51:44 -05:00
J. Nick Koston a8d5ffc141 Merge branch 'dev' into store-yaml-firmware 2026-05-26 09:17:39 -05:00
J. Nick Koston a9ec101631 [store_yaml] Use memcpy_P on ESP8266, std::memcpy elsewhere
Byte-by-byte `progmem_read_byte` for a 512-byte chunk does 512 aligned
32-bit flash reads + shifts on ESP8266. Switching to `memcpy_P` lets the
SDK do bulk aligned-flash copies. Other platforms get a plain
`std::memcpy` since their `PROGMEM` is a no-op and the blob lives in
normal address space.

`memcpy_P` is only available on ESP8266 (via `<pgmspace.h>`), so the
implementation is platform-gated; both paths boil down to a one-call
copy.
2026-05-15 22:36:15 -07:00
J. Nick Koston cd2c1014f3 [store_yaml] Address review: drop esp32-ard test, force on data field
- Remove `test.esp32-ard.yaml`; the esp32 platform is IDF-only for new
  component tests.
- `(force) = true` on `GetYamlResponse.data` so the field is always
  emitted on the wire (matches the camera streaming pattern). `done`
  stays unforced — it's `false` on every chunk except the last, and
  default-value elision saves ~2 bytes per chunk for the firmware to
  TX.
- `total_size` and `encoding` stay first-chunk-only; the client caches
  them. Firmware bandwidth is expensive, client RAM is not.
2026-05-15 10:33:44 -07:00
J. Nick Koston 41081b7278 [store_yaml] Address review: ConfigType typing and drop redundant priority override
- Annotate `_final_validate` and `to_code` with `ConfigType` parameter and
  return types.
- Drop the `get_setup_priority()` override on `StoreYamlComponent`; the
  default `Component::get_setup_priority()` already returns
  `setup_priority::DATA`, so the override was a no-op.
2026-05-15 10:29:05 -07:00
J. Nick Koston f06e96685b [store_yaml] Refactor YAML discovery to share bundle.py's approach
The previous implementation extended `track_yaml_loads` across the entire
validation pass to catch deferred `!include` and package loads, but that also
captured framework YAML loaded internally by component validators (e.g.
LVGL's `hello_world.yaml`) and produced spurious "unresolved substitution"
warnings during the pre-validation force-load. bundle.py already had the
right pattern: a fresh post-validation re-parse plus `force_load_include_files`.

- Lift the discovery into `yaml_util.discover_user_yaml_files` and have both
  `bundle.py` and `config.py` use it (DRY).
- Capture `secrets.yaml` / `secrets.yml` by the *un-resolved* listener fname
  so a `secrets.yaml` symlinked to a non-secrets-named target is still
  flagged for redaction.
- Add a `warn_on_unresolved` flag to `force_load_include_files` so the
  discovery path (where substitutions haven't run) logs at debug instead of
  warning.
- Reject `store_yaml` configs with an unencrypted API via
  `FINAL_VALIDATE_SCHEMA`; an explicit `allow_unencrypted: true` opt-out
  keeps lab setups working (renamed from `allow_unencrypted_api` so the
  integration-test harness's naive `api:` string replacement doesn't
  clobber it).
- Annotate `store_yaml_chunk_buf` with the
  `cppcoreguidelines-avoid-non-const-global-variables` suppression that
  matches other intentional API-side globals.
- Update unit tests to exercise the new `DiscoveredYamlFiles` shape plus a
  symlink-secrets case.
2026-05-15 10:21:00 -07:00
J. Nick Koston 6493fdaba1 [store_yaml] Add end-to-end integration test for native-API recovery
Compiles a host build with `store_yaml`, drives a raw plaintext API socket
(the released aioesphomeapi does not yet know about GetYamlRequest /
GetYamlResponse and would silently drop the streamed bytes as "unknown
message type"), sends GetYamlRequest, accumulates the streamed
GetYamlResponse chunks until done=true, zstd-decompresses, and verifies
both the envelope structure and that the fixture's distinctive markers
(`store-yaml-test`, `store_yaml:`) round-trip back through the recovery
blob.
2026-05-15 10:20:45 -07:00
J. Nick Koston 3d77e3f5dd [store_yaml] Address review: capability bit, dump suppression, paths, unit tests
- Advertise `has_store_yaml` in DeviceInfoResponse so recovery tooling can
  detect support without timing out against firmware built without
  USE_STORE_YAML.
- Suppress GetYamlResponse from the proto dump path. Every chunk would
  otherwise log embedded configuration (including opted-in secrets) on
  builds with HAS_PROTO_MESSAGE_DUMP, and bloat logs during recovery.
- Preserve the include graph for files outside the project root: use
  `os.path.relpath` instead of just the basename so e.g. two
  `../common.yaml` siblings don't collide on recovery.
- Keep `track_yaml_loads` open across `validate_config` so files loaded
  by remote packages and substitution-resolved includes are captured.
- Add focused unit tests for `_gather_files` (redaction, secrets.yml,
  opt-in, dedupe, external-path handling, missing sources) and
  `_pack_envelope` (round-trip, UTF-8 paths, overlong-path guard).
- Make the test_bundle assertion case-insensitive.
2026-05-15 10:20:45 -07:00
J. Nick Koston 7f5de80f81 [store_yaml] Drop dead re-resolve and duplicated SECRETS_FILES check
`track_yaml_loads` already returns resolved Path objects, so re-wrapping
each entry in `Path(...).resolve()` was a no-op. The OR'd
`Path(path).name in SECRETS_FILES` branch always evaluated the same as
the first check on the resolved name; removing it. Document the symlink
edge case the resolver swallows so a future change to the loader can
revisit it.
2026-05-15 10:20:45 -07:00
J. Nick Koston 14628ab3a5 [store_yaml] Add return type annotation on _import_zstd 2026-05-15 10:20:45 -07:00
J. Nick Koston 150a65de8c [store_yaml] Force-load deferred includes; cover secrets.yml
- Promote `_force_load_include_files` from bundle.py to yaml_util.py as
  `force_load_include_files`. bundle.py and the new caller now share it.
- Call it inside the `track_yaml_loads` block in config.py so deferred
  `!include` values, packages, and similar are loaded while the listener
  is active. Without this, `CORE.data["yaml_sources"]` only contained the
  entry YAML, and store_yaml shipped an incomplete recovery blob.
- Treat both `secrets.yaml` and `secrets.yml` as secrets (matches
  `esphome.const.SECRETS_FILES`); also check the original (non-resolved)
  filename to catch a `secrets.yaml` symlinked to an unrelated target.
- Update bundle tests for the new import path and message wording.
2026-05-15 10:20:45 -07:00
J. Nick Koston 783afaeaf2 [store_yaml] Streaming: advance pos only on successful send
Drop the pre-advance + rewind-on-failure dance in try_send_store_yaml_; match
the camera streaming pattern (advance after send_message succeeds, retry the
same chunk on WOULD_BLOCK). Also remove the unreachable `|| pos == 0` branch
in the while condition — on_get_yaml_request short-circuits the zero-size
path before this function is ever called.
2026-05-15 10:20:04 -07:00
J. Nick Koston 59e8242756 [store_yaml] Address review: combined dump_config, encapsulate PROGMEM read
- Coalesce dump_config into a single ESP_LOGCONFIG call.
- Replace the raw get_data() accessor with a read_chunk() helper that copies
  bytes via progmem_read_byte, so callers cannot accidentally dereference the
  PROGMEM pointer directly on ESP8266.
- Update the API streaming handler to use read_chunk().
2026-05-15 10:20:04 -07:00
J. Nick Koston d0510364c9 [store_yaml] Embed user YAML in firmware for recovery
Adds a new opt-in component that compresses the on-disk YAML files with zstd
at codegen time and stores them in PROGMEM. The native API exposes a chunked
GetYaml RPC so a lost configuration can be retrieved from a running device.
Decompression happens client-side; no decompressor is shipped on-device.
2026-05-15 10:20:04 -07:00
26 changed files with 2474 additions and 8 deletions

No files matched your search

+1
View File
@@ -634,6 +634,7 @@ esphome/components/st7789v/* @kbx81
esphome/components/st7920/* @marsjan155
esphome/components/statsd/* @Links2004
esphome/components/stcc4/* @j9brown
esphome/components/store_yaml/* @bdraco
esphome/components/stts22h/* @B48D81EFCC
esphome/components/substitutions/* @esphome/core
esphome/components/sun/* @OttoWinter
+29
View File
@@ -83,6 +83,7 @@ service APIConnection {
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {}
rpc get_yaml(GetYamlRequest) returns (void) {}
}
@@ -401,6 +402,11 @@ message ZWaveProxyCapabilities {
uint32 home_id = 2;
}
message StoreYamlCapabilities {
// Firmware embeds its YAML, so clients can request it with get_yaml
bool supported = 1;
}
message WizardCapabilities {
// True when the device has a wizard configured, so DeviceWizardRequest will be answered
bool configured = 1;
@@ -425,6 +431,7 @@ message DeviceCapabilitiesResponse {
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"];
SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"];
StoreYamlCapabilities store_yaml = 7 [(field_ifdef) = "USE_STORE_YAML"];
}
// ==================== DEVICE WIZARD ====================
@@ -3143,3 +3150,25 @@ message SendspinPairingTokenResponse {
// The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK.
string token = 2 [(max_data_length) = 127];
}
// ==================== STORE YAML ====================
// Streams the compressed user YAML embedded in firmware; the client decompresses.
// A GetYamlRequest during an active transfer is ignored; reconnect to restart.
message GetYamlRequest {
option (id) = 161;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_STORE_YAML";
option (no_delay) = true;
}
message GetYamlResponse {
option (id) = 162;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_STORE_YAML";
// A zstd frame of the EHY1 file envelope, sent from flash
bytes data = 1 [(force) = true, (pointer_to_buffer) = true, (progmem) = true];
bool done = 2 [(force) = true];
// Set on the first chunk only
uint32 total_size = 3;
}
+45 -1
View File
@@ -366,6 +366,13 @@ void APIConnection::loop() {
// (missing a frame is fine, missing a state update is not)
this->try_send_camera_image_();
#endif
#ifdef USE_STORE_YAML
// Guard inline so the idle hot path pays a compare, not a function call.
if (this->store_yaml_pos_ != STORE_YAML_IDLE) {
this->try_send_store_yaml_();
}
#endif
}
void APIConnection::check_keepalive_(uint32_t now) {
@@ -1222,6 +1229,36 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) {
}
#endif
#ifdef USE_STORE_YAML
void APIConnection::on_get_yaml_request() {
// A re-request while a transfer is in flight is ignored (see api.proto)
if (this->store_yaml_pos_ != STORE_YAML_IDLE)
return;
this->store_yaml_pos_ = 0;
this->try_send_store_yaml_();
}
void APIConnection::try_send_store_yaml_() {
// Advance only after a successful send, so a full TX buffer retries the same chunk from loop()
while (this->helper_->can_write_without_blocking()) {
const size_t to_send = std::min(STORE_YAML_DATA_SIZE - this->store_yaml_pos_, MAX_BATCH_PACKET_SIZE);
GetYamlResponse resp;
resp.data = STORE_YAML_DATA + this->store_yaml_pos_;
resp.data_len = to_send;
if (this->store_yaml_pos_ == 0)
resp.total_size = STORE_YAML_DATA_SIZE;
resp.done = this->store_yaml_pos_ + to_send == STORE_YAML_DATA_SIZE;
if (!this->send_message(resp))
return;
if (resp.done) {
this->store_yaml_pos_ = STORE_YAML_IDLE;
return;
}
this->store_yaml_pos_ += to_send;
}
}
#endif
#ifdef USE_HOMEASSISTANT_TIME
void APIConnection::on_get_time_response(const GetTimeResponse &value) {
if (homeassistant::global_homeassistant_time != nullptr) {
@@ -2122,6 +2159,9 @@ bool APIConnection::send_device_capabilities_response_() {
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id();
#endif
#ifdef USE_STORE_YAML
resp.store_yaml.supported = true;
#endif
return this->send_message(resp);
}
@@ -2364,10 +2404,14 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn,
const void *msg) {
#ifdef HAS_PROTO_MESSAGE_DUMP
// Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise)
// Skip dump for log messages (recursive logging risk), camera frames (high-frequency noise)
// and YAML chunks (may contain secrets)
if (message_type != SubscribeLogsResponse::MESSAGE_TYPE
#ifdef USE_CAMERA
&& message_type != CameraImageResponse::MESSAGE_TYPE
#endif
#ifdef USE_STORE_YAML
&& message_type != GetYamlResponse::MESSAGE_TYPE
#endif
) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
+15
View File
@@ -32,6 +32,11 @@ namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
#ifdef USE_STORE_YAML
/// The zstd YAML envelope generated by store_yaml, STORE_YAML_DATA_SIZE bytes in flash.
extern const uint8_t STORE_YAML_DATA[] PROGMEM;
#endif
// One shared flash string for every refused-frame warning: send_message()
// fails as soon as the TCP buffer is full, and each caller only pays for its
// short name. The guard drops the helper and its arguments below WARN.
@@ -123,6 +128,9 @@ class APIConnection final : public APIServerConnectionBase {
void set_camera_state(std::shared_ptr<camera::CameraImage> image);
void on_camera_image_request(const CameraImageRequest &msg);
#endif
#ifdef USE_STORE_YAML
void on_get_yaml_request();
#endif
#ifdef USE_CLIMATE
bool send_climate_state(climate::Climate *climate);
void on_climate_command_request(const ClimateCommandRequest &msg);
@@ -421,6 +429,13 @@ class APIConnection final : public APIServerConnectionBase {
void try_send_camera_image_();
#endif
#ifdef USE_STORE_YAML
void try_send_store_yaml_();
static constexpr size_t STORE_YAML_IDLE = std::numeric_limits<size_t>::max();
// Streaming offset into STORE_YAML_DATA, or STORE_YAML_IDLE
size_t store_yaml_pos_{STORE_YAML_IDLE};
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void process_state_subscriptions_();
#endif
+37
View File
@@ -299,6 +299,19 @@ uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) {
return size;
}
#endif
#ifdef USE_STORE_YAML
uint8_t *StoreYamlCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const StoreYamlCapabilities *>(self);
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.supported);
return pos;
}
uint32_t StoreYamlCapabilities::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const StoreYamlCapabilities *>(self);
uint32_t size = 0;
size += ProtoSize::calc_bool(1, msg.supported);
return size;
}
#endif
#ifdef USE_API_WIZARD
uint8_t *WizardCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const WizardCapabilities *>(self);
@@ -348,6 +361,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res
#endif
#ifdef USE_SENDSPIN
pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin);
#endif
#ifdef USE_STORE_YAML
pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.store_yaml);
#endif
return pos;
}
@@ -373,6 +389,9 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) {
#endif
#ifdef USE_SENDSPIN
size += ProtoSize::calc_message(1, msg.sendspin.calculate_size());
#endif
#ifdef USE_STORE_YAML
size += ProtoSize::calc_message(1, msg.store_yaml.calculate_size());
#endif
return size;
}
@@ -4407,6 +4426,24 @@ uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) {
return size;
}
#endif
#ifdef USE_STORE_YAML
uint8_t *GetYamlResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const GetYamlResponse *>(self);
pos = ProtoEncode::encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len);
pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16);
pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, msg.done ? 0x01 : 0x00);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.total_size);
return pos;
}
uint32_t GetYamlResponse::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const GetYamlResponse *>(self);
uint32_t size = 0;
size += ProtoSize::calc_length_force(1, msg.data_len);
size += ProtoSize::calc_bool_force(1);
size += ProtoSize::calc_uint32(1, msg.total_size);
return size;
}
#endif
#ifndef HAS_PROTO_MESSAGE_DUMP
static_assert(!std::is_polymorphic_v<HelloRequest>, "decodable messages carry no vtable");
static_assert(!std::is_polymorphic_v<DisconnectRequest>, "decodable messages carry no vtable");
+46 -1
View File
@@ -734,6 +734,23 @@ class ZWaveProxyCapabilities final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_STORE_YAML
class StoreYamlCapabilities final : public ProtoMessage {
public:
bool supported{false};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_API_WIZARD
class WizardCapabilities final : public ProtoMessage {
public:
@@ -772,7 +789,7 @@ class SendspinCapabilities final : public ProtoMessage {
class DeviceCapabilitiesResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 136;
static constexpr uint8_t ESTIMATED_SIZE = 153;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
#endif
@@ -793,6 +810,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage {
#endif
#ifdef USE_SENDSPIN
SendspinCapabilities sendspin{};
#endif
#ifdef USE_STORE_YAML
StoreYamlCapabilities store_yaml{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -4209,5 +4229,30 @@ class SendspinPairingTokenResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_STORE_YAML
class GetYamlResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 162;
static constexpr uint8_t ESTIMATED_SIZE = 25;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("get_yaml_response"); }
#endif
const uint8_t *data{nullptr};
uint16_t data_len{0};
bool done{false};
uint32_t total_size{0};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
} // namespace esphome::api
+21
View File
@@ -1127,6 +1127,13 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_STORE_YAML
const char *StoreYamlCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("StoreYamlCapabilities"));
dump_field(out, ESPHOME_PSTR("supported"), this->supported);
return out.c_str();
}
#endif
#ifdef USE_API_WIZARD
const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities"));
@@ -1175,6 +1182,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": ");
this->sendspin.dump_to(out);
out.append("\n");
#endif
#ifdef USE_STORE_YAML
out.append(2, ' ').append_p(ESPHOME_PSTR("store_yaml")).append(": ");
this->store_yaml.dump_to(out);
out.append("\n");
#endif
return out.c_str();
}
@@ -3016,6 +3028,15 @@ const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_STORE_YAML
const char *GetYamlResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("GetYamlResponse"));
dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
dump_field(out, ESPHOME_PSTR("done"), this->done);
dump_field(out, ESPHOME_PSTR("total_size"), this->total_size);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -760,6 +760,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_sendspin_pairing_token_request();
break;
}
#endif
#ifdef USE_STORE_YAML
case 161 /* GetYamlRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_get_yaml_request"));
#endif
this->on_get_yaml_request();
break;
}
#endif
default:
break;
+4
View File
@@ -256,6 +256,10 @@ class APIServerConnectionBase {
#ifdef USE_SENDSPIN
void on_sendspin_pairing_token_request() {};
#endif
#ifdef USE_STORE_YAML
void on_get_yaml_request() {};
#endif
};
} // namespace esphome::api
+723
View File
@@ -0,0 +1,723 @@
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass
import logging
import os
from pathlib import Path
import re
import struct
import yaml
from esphome import yaml_util
import esphome.codegen as cg
from esphome.components import noise, packages
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
from esphome.const import (
CONF_API,
CONF_DEFAULTS,
CONF_ID,
CONF_PACKAGES,
CONF_SSID,
CONF_SUBSTITUTIONS,
)
from esphome.core import CORE, EsphomeError, Lambda
from esphome.expression import SUBSTITUTION_VARIABLE_PROG
import esphome.final_validate as fv
from esphome.git import URL_USERINFO_RE
from esphome.helpers import ensure_unique_string, list_starts_with, zstd_module
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@bdraco"]
DEPENDENCIES = ["api"]
DOMAIN = "store_yaml"
CONF_INCLUDE_SECRETS = "include_secrets"
CONF_ALLOW_UNENCRYPTED = "allow_unencrypted"
# 22 is the max and gives ~70-90% reduction on YAML.
ZSTD_LEVEL = 22
# "EHY1" = ESPHome YAML, version 1.
ENVELOPE_MAGIC = b"EHY1"
SECRETS_SKELETON_HEADER = (
"# Redacted by store_yaml. Fill in these values and the recovered\n"
"# config is ready to flash.\n"
)
UNCAPTURED_NOTE_PATH = "store_yaml_uncaptured.yaml"
_FIX = (
"Reference them with `!secret`, or set `include_secrets: true` to embed "
"secrets deliberately."
)
# Substitution names to every value they may take in one file.
_Scope = dict[str, list[object]]
# An `!include`, its vars before any folding, and the config path of its site.
_Include = tuple[yaml_util.IncludeFile, dict[str, object], tuple[str, ...]]
# Stands in for an integer credential at its keys and definitions.
_INT_MARK = "\x00"
# A removed branch is never validated, so its credentials are known only by name.
_CREDENTIAL_WORDS = (
"password",
"key",
"secret",
"token",
"psk",
"tlv",
"pairing_code",
"pin_code",
)
# Every key validated with cv.sensitive (a test keeps this complete) and the
# packet_transport `encryption: <key>` shorthand: credentials wherever they appear,
# also when a merge or override drops them before validation. SSIDs are broadcast,
# and a bare `key` also names keypad keys; both are checked where validation kept them.
_SENSITIVE_KEYS = frozenset(
{"password", "new_password", "private_key", "peer_preshared_key"}
| {"client_certificate_key", "network_key", "old_key", "pin_code", "pskc", "tlv"}
| {"static_pairing_code", "encryption", "bindkey", "decryption_key", "auth_key"}
)
# Keys under these name declared arguments, not values (`variables: {password: string}`).
_DECLARATIONS = frozenset({"variables", "parameters"})
# cv.bind_key's normalised output: 32 uppercase hex digits.
_BIND_KEY_RE = re.compile(r"[0-9A-F]{32}")
# A quoted string or a number: a lambda without one cannot spell a credential.
_LITERAL_RE = re.compile(r"""["']|(?<![\w.])\d""")
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_INCLUDE_SECRETS, default=False): cv.boolean,
cv.Optional(CONF_ALLOW_UNENCRYPTED, default=False): cv.boolean,
}
)
def _final_validate(config: ConfigType) -> ConfigType:
"""Require an API encryption key unless ``allow_unencrypted`` is set."""
full = fv.full_config.get()
if noise.static_encryption_key(full.get(CONF_API, {})) is not None:
return config
if config.get(CONF_ALLOW_UNENCRYPTED):
_LOGGER.warning(
"store_yaml is enabled without API encryption; any client that can "
"reach the device on the network can pull the embedded YAML%s.",
" including the verbatim contents of secrets.yaml"
if config.get(CONF_INCLUDE_SECRETS)
else "",
)
return config
raise cv.Invalid(
"store_yaml requires API encryption (`api.encryption.key`), or any client "
"that reaches the device can read the embedded YAML. Set "
"`store_yaml.allow_unencrypted: true` to accept that."
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _gather_files(
discovered: yaml_util.DiscoveredYamlFiles,
) -> tuple[list[tuple[str, Path]], set[str]]:
"""(envelope path, named path) per name a file was reached by, and the envelope
paths of secrets files."""
if discovered.load_errors:
raise EsphomeError(
"store_yaml: could not load all configuration files: "
+ "; ".join(discovered.load_errors)
)
if discovered.unresolved:
_LOGGER.warning(
"store_yaml: %d !include path(s) use substitutions and cannot be "
"captured (%s); the embedded recovery data will not contain them",
len(discovered.unresolved),
", ".join(discovered.unresolved),
)
root = yaml_util.named_path(CORE.config_path).parent
entries: list[tuple[str, Path]] = []
secret_rels: set[str] = set()
for path in discovered.files:
for alias in discovered.aliases.get(path, [path]):
# Keep ".." for files outside the root so same-named files don't collide.
try:
rel_str = alias.relative_to(root, walk_up=True).as_posix()
except ValueError as err:
raise EsphomeError(
f"store_yaml: cannot place {alias} in the recovery envelope; "
f"it does not share a root with {root}: {err}"
) from err
if path in discovered.secrets:
secret_rels.add(rel_str)
entries.append((rel_str, alias))
return entries, secret_rels
def _read_files_verbatim(entries: list[tuple[str, Path]]) -> list[tuple[str, bytes]]:
"""Read each file's exact on-disk bytes (the `include_secrets: true` path)."""
files: list[tuple[str, bytes]] = []
for rel, path in entries:
try:
files.append((rel, path.read_bytes()))
except OSError as err:
raise EsphomeError(
f"store_yaml: cannot read tracked YAML file {path}: {err}"
) from err
return files
class _IncludeName(str):
"""An `!include` file name: scanned for secrets, never taken for the value it loads."""
__slots__ = ()
def _iter_nodes(
node: object, path: tuple[str, ...] = ()
) -> Generator[tuple[tuple[str, ...], object, bool]]:
"""Yield (config_path, value, is_key) for every key and scalar, unwrapping the
tagged wrappers the dumper writes as text."""
if isinstance(node, dict):
for key, value in node.items():
yield path, str(key), True
yield from _iter_nodes(value, (*path, str(key)))
elif isinstance(node, (list, tuple)):
for item in node:
yield from _iter_nodes(item, path)
elif isinstance(node, Lambda):
yield path, node, False
elif isinstance(node, (Extend, Remove)):
yield path, node.value, False
elif isinstance(node, yaml_util.IncludeFile):
yield path, _IncludeName(node.file), False
if isinstance(node.condition, str):
yield path, node.condition, False
if node.vars:
yield from _iter_nodes(node.vars, path)
elif isinstance(node, (str, int, float)) and not isinstance(node, bool):
yield path, node, False
def _str_keys(value: object) -> dict[str, object]:
"""`value` as a mapping with string keys, or empty."""
return {str(k): v for k, v in value.items()} if isinstance(value, dict) else {}
def _ends_with(full: tuple[str, ...], part: tuple[str, ...]) -> bool:
"""`part` ends `full`, skipping segments validation inserted (`wifi.password`
to `wifi.networks.password`)."""
if full[-1] != part[-1]:
return False
rest = iter(reversed(full[:-1]))
return all(seg in rest for seg in reversed(part[:-1]))
def _merged(path: tuple[str, ...]) -> set[tuple[str, ...]]:
"""`path` as validation sees it: every `packages[.<name>]` prefix merges away."""
if path and path[0] == CONF_PACKAGES:
return _merged(path[1:]) | _merged(path[2:])
return {path}
def _defines_substitution(path: tuple[str, ...]) -> bool:
"""`path` is a `substitutions:` entry, also inside an inline package."""
return any(len(p) > 1 and p[0] == CONF_SUBSTITUTIONS for p in _merged(path))
@dataclass
class _SensitiveValue:
secret_name: str
config_path: str # dotted path, for messages (never the value itself)
from_secret: bool # loaded through a real `!secret`
# Keys the value sits at, to tell its own occurrences from collisions.
sensitive_keys: set[str]
# Integers skip the substring scan; short PINs would match unrelated text.
is_int: bool = True
def _include_scopes(
includes: dict[str, list[_Include]],
trees: dict[str, object],
sources: dict[str, Path],
) -> tuple[dict[str, _Scope], dict[str, list[tuple[str, ...]]]]:
"""Per file: every value a substitution name may take (per include: its vars,
else the file's `defaults:`, else the caller's chain, else substitutions) and
the config paths its content lands under."""
substitutions: _Scope = {}
for tree in trees.values():
for path, node, is_key in _iter_nodes(tree):
if not is_key and _defines_substitution(path):
substitutions.setdefault(path[-1], []).append(node)
defaults = {
rel: _str_keys(_str_keys(tree).get(CONF_DEFAULTS))
for rel, tree in trees.items()
}
rels_by_path: dict[Path, list[str]] = {}
for rel, path in sources.items():
rels_by_path.setdefault(path.resolve(), []).append(rel)
edges: dict[str, list[tuple[str, dict[str, object], tuple[str, ...]]]] = {}
for src, items in includes.items():
for include, vars, site in items:
if include.has_unresolved_file() or not yaml_util.include_enabled(include):
continue
target = (include.parent_file.parent / include.file).resolve()
edges.setdefault(src, []).extend(
(rel, vars, site) for rel in rels_by_path.get(target, ())
)
# Each way a file is reached: its vars chain, built as the loader does, and
# the include-site prefix; a caller's chain flows into nested includes.
targeted = {rel for targets in edges.values() for rel, _, _ in targets}
invocations: dict[str, list[tuple[dict[str, object], tuple[str, ...]]]] = {}
pending = [(rel, defaults[rel], (), ()) for rel in trees if rel not in targeted]
while pending:
rel, chain, prefix, lineage = pending.pop()
if (chain, prefix) in invocations.setdefault(rel, []):
continue
invocations[rel].append((chain, prefix))
pending.extend(
(dst, {**chain, **defaults[dst], **vars}, prefix + site, (*lineage, rel))
for dst, vars, site in edges.get(rel, ())
if dst not in lineage # an include cycle never ends otherwise
)
scopes: dict[str, _Scope] = {}
prefixes: dict[str, list[tuple[str, ...]]] = {}
for rel in trees:
chains = invocations.get(rel) or [(defaults[rel], ())]
scope: _Scope = {}
for chain, _ in chains:
for name in {*substitutions, *chain}:
scope.setdefault(name, []).extend(
[chain[name]] if name in chain else substitutions[name]
)
scopes[rel] = scope
prefixes[rel] = [prefix for _, prefix in chains]
return scopes, prefixes
def _check_sensitive_usage(
sensitive: dict[str, _SensitiveValue],
sensitive_paths: set[tuple[str, ...]],
trees: dict[str, object],
scopes: dict[str, _Scope],
prefixes: dict[str, list[tuple[str, ...]]],
removed: set[tuple[str, ...]],
) -> None:
"""A sensitive value is a whole scalar (swapped) or fails, except a secret SSID
inside a name (warned); unverifiable credential locations and URL userinfo fail."""
credential_keys = {sp[-1] for sp in sensitive_paths if sp}
def verified(text: str) -> bool:
return not text or text in sensitive or yaml_util.is_secret(text) is not None
def references_verified(
text: str, scope: _Scope, seen: frozenset = frozenset()
) -> bool:
"""Every reference names definitions that are verified or alias verified ones."""
names = [m.strip("{}") for m in SUBSTITUTION_VARIABLE_PROG.findall(text)]
return bool(names) and all(
name in scope
and name not in seen
and all(
verified(str(v))
or SUBSTITUTION_VARIABLE_PROG.fullmatch(str(v)) is not None
and references_verified(str(v), scope, seen | {name})
for v in scope[name]
)
for name in names
)
def credential_path(path: tuple[str, ...]) -> bool:
"""Whether `path` lands where the validated config holds a credential."""
if not path:
return False
merged = _merged(path)
if path[-1] in _SENSITIVE_KEYS and not _DECLARATIONS.intersection(path[-2:-1]):
return True
if any(p and any(list_starts_with(p, r) for r in removed) for p in merged):
return path[-1] in credential_keys or any(
word in path[-1] for word in _CREDENTIAL_WORDS
)
return path[-1] in credential_keys and any(
# One segment says nothing about where an `!include_dir_*` file lands
len(p) > 1 and any(_ends_with(sp, p) for sp in sensitive_paths)
for p in merged
)
def shown(path: tuple[str, ...]) -> str:
"""`path` for messages, credential segments masked."""
return ".".join(
f"<!secret {sensitive[seg].secret_name}>" if seg in sensitive else seg
for seg in path
)
failures: dict[str, list[str]] = {
"appear embedded inside larger values or as mapping keys, which redaction "
"does not replace": [],
"cannot be confirmed redacted: they sit at a credential key or location but "
"are not a known secret (for example a literal password, or one a merge, "
"override or `!remove` dropped before validation)": [],
}
embedded, unverified = failures.values()
for rel, tree in trees.items():
scope = scopes[rel]
for path, node, is_key in _iter_nodes(tree):
text = str(node)
where = (
f"a mapping key under {shown(path) or 'the document root'}"
if is_key
else shown(path)
)
if verified(text):
# A whole scalar is swapped for its `!secret`; a key never is.
if is_key and (info := sensitive.get(text)) is not None:
embedded.append(f"{info.config_path} (as {where} in {rel})")
continue
if not is_key and any(
SUBSTITUTION_VARIABLE_PROG.sub("", userinfo) not in ("", ":")
or not references_verified(userinfo, scope)
for userinfo in URL_USERINFO_RE.findall(text)
):
unverified.append(f"{where} in {rel} (a URL with credentials)")
elif (
not is_key
and not isinstance(node, _IncludeName)
and any(credential_path(prefix + path) for prefix in prefixes[rel])
and not (
_LITERAL_RE.search(text) is None
if isinstance(node, Lambda)
else SUBSTITUTION_VARIABLE_PROG.fullmatch(text) is not None
and references_verified(text, scope)
)
):
unverified.append(f"{where} in {rel}")
continue
for value, other in sensitive.items():
# A key the value is sensitive at (`password` for `word`) is structure.
if (
other.is_int
or value not in text
or (is_key and text in other.sensitive_keys)
):
continue
if other.from_secret and other.sensitive_keys <= {CONF_SSID}:
# A broadcast SSID inside a name is common; warn without the value.
_LOGGER.warning(
"store_yaml: !secret %s is not redacted inside %s in %s",
other.secret_name,
where,
rel,
)
else:
embedded.append(f"{other.config_path} (inside {where} in {rel})")
for why, hits in failures.items():
if hits:
raise EsphomeError(
f"store_yaml: sensitive value(s) {why}: {', '.join(hits)}. {_FIX}"
)
def _collect_sensitive_values() -> tuple[
dict[str, _SensitiveValue], set[tuple[str, ...]]
]:
"""Map each cv.sensitive value to its `!secret` name (inline values get one from
their path) and collect every sensitive path, empty values included."""
used = yaml_util.registered_secret_names()
result: dict[str, _SensitiveValue] = {}
paths: set[tuple[str, ...]] = set()
for path, node, is_key in _iter_nodes(CORE.config):
if is_key or not isinstance(node, yaml_util.Sensitive):
continue
paths.add(path)
if isinstance(node, Lambda):
if _LITERAL_RE.search(node.value) is not None:
raise EsphomeError(
f"store_yaml: the credential at {'.'.join(path)} is set by a "
f"lambda holding a literal, which cannot be redacted. {_FIX}"
)
continue
if isinstance(node, str) and not node:
continue
# Decimal for ints, to match the raw tree's parsed scalars, not HexInt's hex.
value = str(int(node)) if isinstance(node, int) else str(node)
if (entry := result.get(value)) is None:
name = yaml_util.is_secret(value)
if name is None and _BIND_KEY_RE.fullmatch(value):
# cv.bind_key uppercases the key; its secret keeps the user's spelling
secrets = yaml_util.registered_secret_values().items()
name = next((n for v, n in secrets if v.upper() == value), None)
from_secret = name is not None
if not from_secret:
name = ensure_unique_string("_".join(path) or "secret", used)
used.add(name)
entry = result[value] = _SensitiveValue(
name, ".".join(path), from_secret, set()
)
entry.is_int = entry.is_int and isinstance(node, int)
entry.sensitive_keys.update(path[-1:])
return result, paths
def _symlink_descriptions(discovered: yaml_util.DiscoveredYamlFiles) -> list[str]:
"""Each directory symlink the config went through, as `link -> target`."""
root = Path(CORE.config_path).absolute().parent
return [
f"{link.relative_to(root, walk_up=True).as_posix()} -> "
f"{Path(os.path.relpath(target, link.parent.resolve())).as_posix()}"
for link, target in discovered.links.items()
]
def _uncaptured_note(
unresolved: list[str], remote_packages: list[str], symlinks: list[str]
) -> tuple[str, bytes] | None:
"""A comment-only entry listing uncaptured content, or None if there is none."""
sections = (
(
"These !include paths use substitutions; restore the files manually:",
unresolved,
),
(
"These packages come from remote sources; re-fetch them to complete this config:",
remote_packages,
),
("These directories were symlinks; recreate them as link -> target:", symlinks),
)
body = "".join(
f"# {title}\n" + "".join(f"# {item}\n" for item in items)
for title, items in sections
if items
)
if not body:
return None
header = "# store_yaml: the following content could not be captured.\n"
return UNCAPTURED_NOTE_PATH, (header + body).encode("utf-8")
def _remote_package_descriptions() -> list[str]:
"""Each remote package source as its secret name or masked URL, plus ref."""
def describe(text: str) -> str:
if (name := yaml_util.is_secret(text)) is not None:
return f"!secret {name}"
# A secret substituted into the URL or ref is shown by name, longest first so
# a secret containing another is replaced whole.
secrets = yaml_util.registered_secret_values().items()
for value, name in sorted(secrets, key=lambda item: -len(item[0])):
if value and value in text:
text = text.replace(value, f"<!secret {name}>")
return URL_USERINFO_RE.sub("://***@", text)
return [
describe(source.url) + (f"@{describe(source.ref)}" if source.ref else "")
for source in packages.get_remote_package_sources()
]
def _build_secrets_skeleton(keys: set[str]) -> bytes:
# Plain PyYAML quotes a key such as "true" and does no secret substitution.
body = yaml.safe_dump(dict.fromkeys(sorted(keys), ""), sort_keys=False)
return (SECRETS_SKELETON_HEADER + (body if keys else "")).encode("utf-8")
def _generate_redacted_files(
entries: list[tuple[str, Path]], secret_rels: set[str], remote_packages: list[str]
) -> list[tuple[str, bytes]]:
"""Re-dump captured files with sensitive values as `!secret`, secrets files as a
skeleton; comments and formatting are lost."""
sensitive, sensitive_paths = _collect_sensitive_values()
includes: dict[str, list[_Include]] = {}
removed: dict[str, set[tuple[str, ...]]] = {}
def prepare(
node: object,
source: Path,
rel: str,
path: tuple[str, ...],
in_vars: bool = False,
) -> object:
"""Fold container secrets to their registered text (a plain str would skip
the secret check), swap integer credentials at their own keys, point each
`!include` at its target's captured name, collect includes and removals."""
if isinstance(node, (dict, list)) and yaml_util.is_secret(node) is not None:
return yaml_util.make_data_base(str(node))
if isinstance(node, dict):
for key, value in node.items():
node[key] = prepare(value, source, rel, (*path, str(key)), in_vars)
elif isinstance(node, list):
for i, item in enumerate(node):
node[i] = prepare(item, source, rel, path, in_vars)
elif isinstance(node, Remove):
# `id: !remove x` drops the whole list item, not just its id.
removed.setdefault(rel, set()).add(
path[:-1] if path[-1:] == (CONF_ID,) else path
)
elif isinstance(node, (int, str)) and not isinstance(node, bool):
# An integer credential, quoted or not, at its own keys or as a variable
# definition a credential may reference.
info = sensitive.get(str(node))
at_key = info is not None and info.sensitive_keys.intersection(path[-1:])
definition = (
in_vars or path[:1] == (CONF_DEFAULTS,) or _defines_substitution(path)
)
if info and info.is_int and (at_key or definition):
return yaml_util.make_data_base(_INT_MARK + info.secret_name)
# cv.bind_key uppercases the key, so match an inline one the same way
upper = node.upper() if isinstance(node, str) else ""
bind = sensitive.get(upper) if _BIND_KEY_RE.fullmatch(upper) else None
if bind and bind.sensitive_keys.intersection(path[-1:]):
return yaml_util.make_data_base(upper)
elif isinstance(node, yaml_util.IncludeFile):
vars = _str_keys(node.vars)
if yaml_util.is_secret(node.vars) is not None:
vars = dict.fromkeys(vars, node.vars)
includes.setdefault(rel, []).append((node, vars, path))
if node.vars:
node.vars = prepare(node.vars, source, rel, path, in_vars=True)
if not node.has_unresolved_file():
# Where the loader read it, through directory symlinks and `!include_dir_*` moves.
target = yaml_util.named_path(Path(node.parent_file).parent / node.file)
node = node.with_file(
Path(os.path.relpath(target, source.parent)).as_posix()
)
return node
for rel, path in entries:
# A secrets file becomes a key skeleton; what it includes would ship verbatim.
if rel in secret_rels and any(
isinstance(token, yaml.TagToken) and token.value[1].startswith("include")
for token in yaml.scan(path.read_text(encoding="utf-8"), yaml.SafeLoader)
):
raise EsphomeError(
f"store_yaml: {rel} uses !include, which redaction cannot follow. "
f"Inline the files into {rel}, or set `include_secrets: true`."
)
captured = {rel: path for rel, path in entries if rel not in secret_rels}
trees: dict[str, object] = {}
for rel, path in captured.items():
# A document that is one `!include` keeps its vars and condition.
with path.open(encoding="utf-8") as handle:
tree = yaml_util.parse_yaml(path, handle, resolve_document_include=False)
trees[rel] = prepare(tree, yaml_util.named_path(path), rel, ())
scopes, prefixes = _include_scopes(includes, trees, captured)
removed_at = {
merged
for rel, paths in removed.items()
for path in paths
for prefix in prefixes[rel]
for merged in _merged(prefix + path)
if merged
}
# An integer credential is swapped by prepare(), not by the dumper, so an
# unrelated short number (`accuracy_decimals: 0`) is left alone.
registered = {
(_INT_MARK + info.secret_name if info.is_int else value): info.secret_name
for value, info in sensitive.items()
}
with yaml_util.secret_values_registered(registered) as skeleton_keys:
_check_sensitive_usage(
sensitive, sensitive_paths, trees, scopes, prefixes, removed_at
)
texts = {rel: yaml_util.dump(tree) for rel, tree in trees.items()}
# An inline value whose name was never emitted was not found as a whole scalar.
leaked = [
info.config_path
for info in sensitive.values()
if not info.from_secret and info.secret_name not in skeleton_keys
]
if leaked:
raise EsphomeError(
f"store_yaml: could not redact the sensitive value(s) of {', '.join(leaked)}; "
"not found in any captured file (composed via substitutions, set with -s, "
f"or defined in a remote package: {', '.join(remote_packages) or 'none'}). "
+ _FIX
)
skeleton = _build_secrets_skeleton(skeleton_keys)
result = [
(rel, skeleton if rel in secret_rels else texts[rel].encode("utf-8"))
for rel, _ in entries
]
if skeleton_keys and yaml_util.SECRET_YAML not in secret_rels:
# `!secret` looks for secrets.yaml beside the config; ship a skeleton there.
result.append((yaml_util.SECRET_YAML, skeleton))
return result
def _pack_envelope(files: list[tuple[str, bytes]]) -> bytes:
"""EHY1 | u32 count | {u16 path_len, path, u32 content_len, content}..., little-endian."""
paths = [path for path, _ in files]
if len(set(paths)) != len(paths):
raise EsphomeError(f"store_yaml: duplicate envelope path in {paths}")
return (
ENVELOPE_MAGIC
+ struct.pack("<I", len(files))
+ b"".join(
struct.pack("<H", len(encoded := path.encode("utf-8")))
+ encoded
+ struct.pack("<I", len(content))
+ content
for path, content in files
)
)
def unpack_envelope(blob: bytes) -> dict[str, bytes]:
"""Reference decoder; rejects absolute paths, allows `..`, so disk writers confine paths."""
if blob[:4] != ENVELOPE_MAGIC:
raise EsphomeError("envelope must start with EHY1 magic")
files: dict[str, bytes] = {}
try:
(count,) = struct.unpack_from("<I", blob, 4)
pos = 8
for _ in range(count):
(path_len,) = struct.unpack_from("<H", blob, pos)
path = blob[pos + 2 : pos + 2 + path_len].decode("utf-8")
(size,) = struct.unpack_from("<I", blob, pos + 2 + path_len)
pos += 6 + path_len
if path in files or path.startswith(("/", "\\")) or path[1:2] == ":":
raise EsphomeError(f"envelope path rejected: {path}")
files[path], pos = blob[pos : pos + size], pos + size
except (struct.error, UnicodeDecodeError) as err:
raise EsphomeError(f"corrupt envelope: {err}") from err
if pos != len(blob):
raise EsphomeError("corrupt envelope: size mismatch")
return files
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_STORE_YAML")
# A fresh parse captures only user files and refills the secret registry.
discovered = yaml_util.discover_user_yaml_files(CORE.config_path)
entries, secret_rels = _gather_files(discovered)
remote_packages = _remote_package_descriptions()
if config[CONF_INCLUDE_SECRETS]:
files = _read_files_verbatim(entries)
else:
files = _generate_redacted_files(entries, secret_rels, remote_packages)
if remote_packages:
_LOGGER.warning(
"store_yaml: remote packages are not embedded, only recorded: %s",
", ".join(remote_packages),
)
note = _uncaptured_note(
discovered.unresolved, remote_packages, _symlink_descriptions(discovered)
)
if note is not None:
files.append(note)
envelope = _pack_envelope(files)
compressed = zstd_module().compress(envelope, level=ZSTD_LEVEL)
_LOGGER.info(
"store_yaml: embedding %d file(s) as %d bytes (%d uncompressed, %.1f%% ratio)",
len(files),
len(compressed),
len(envelope),
100.0 * len(compressed) / len(envelope),
)
cg.extern_progmem_array("esphome::api::STORE_YAML_DATA", cg.uint8, list(compressed))
cg.add_define("STORE_YAML_DATA_SIZE", len(compressed))
+2
View File
@@ -377,8 +377,10 @@
#define USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
#define USE_API_USER_DEFINED_ACTION_RESPONSES
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#define USE_STORE_YAML
#define API_MAX_SEND_QUEUE 8
#define API_WIZARD_DATA_SIZE 1
#define STORE_YAML_DATA_SIZE 1
#define API_WIZARD_INPUT_COUNT 1
#define MAX_API_CONNECTIONS 6
// The Improv library is not in the Zephyr tidy environment
+1
View File
@@ -101,6 +101,7 @@ ISOLATED_COMPONENTS = {
"modem": "Cellular-only node with api - grouping with wifi-bearing components hides that case",
"neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)",
"packages": "cannot merge packages",
"store_yaml": "Embeds the test's own YAML; grouping would change the blob and its redaction",
"tinyusb": "Conflicts with usb_host component - cannot be used together",
"tinyusb_keyboard": "Depends on tinyusb which conflicts with usb_host",
"uart_mux": "Depends on tinyusb which conflicts with usb_host",
+4
View File
@@ -0,0 +1,4 @@
api:
store_yaml:
allow_unencrypted: true
@@ -0,0 +1,6 @@
wifi:
ssid: MySSID
password: password1
packages:
store_yaml: !include common.yaml
@@ -0,0 +1,6 @@
wifi:
ssid: MySSID
password: password1
packages:
store_yaml: !include common.yaml
@@ -0,0 +1,6 @@
wifi:
ssid: MySSID
password: password1
packages:
store_yaml: !include common.yaml
@@ -0,0 +1,4 @@
network:
packages:
store_yaml: !include common.yaml
@@ -0,0 +1,6 @@
wifi:
ssid: MySSID
password: password1
packages:
store_yaml: !include common.yaml
@@ -0,0 +1,6 @@
wifi:
ssid: MySSID
password: password1
packages:
store_yaml: !include common.yaml
@@ -0,0 +1,6 @@
wifi:
ssid: MySSID
password: password1
packages:
store_yaml: !include common.yaml
@@ -0,0 +1,9 @@
wifi:
ssid: MySSID
password: password1
api:
encryption:
key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
store_yaml:
@@ -0,0 +1,18 @@
esphome:
name: store-yaml-test
areas:
- id: living_room
name: "Living Room"
host:
logger:
api:
ota:
- platform: esphome
password: recoverme123
store_yaml:
allow_unencrypted: true
@@ -0,0 +1,24 @@
esphome:
name: store-yaml-backpressure
host:
logger:
api:
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [sndbuf_pin_component]
# Pins the device's socket send buffers for deterministic TCP backpressure
sndbuf_pin_component:
buffer_size: SERVER_SNDBUF
store_yaml:
allow_unencrypted: true
substitutions:
# GENERATED_SUBSTITUTIONS
+25 -6
View File
@@ -1,7 +1,8 @@
"""Minimal plaintext native-api client over a raw socket.
Reads only when told to, so tests control when the TCP pipe backs up toward
the device; message types are counted and each frame is kept for inspection.
the device; message types are counted and each frame is kept for inspection,
and read_frame() hands them out in arrival order.
"""
from __future__ import annotations
@@ -137,6 +138,7 @@ class RawApiClient:
def __init__(self, port: int, recv_buffer_size: int | None = None) -> None:
self._port = port
self._parser = FrameParser()
self._read_pos = 0 # frames before this index were consumed by read_frame()
self.bytes_received = 0
self.frame_counts: Counter[int] = Counter()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -179,21 +181,38 @@ class RawApiClient:
loop = asyncio.get_running_loop()
await loop.sock_sendall(self._sock, encode_frame(msg_type, payload))
async def _recv_once(self) -> None:
data = await asyncio.get_running_loop().sock_recv(self._sock, _READ_CHUNK)
assert data, "server closed the connection unexpectedly"
self.bytes_received += len(data)
self.frame_counts.update(self._parser.feed(data))
async def read_until_frame(
self, msg_type: int, timeout: float = 10.0, count: int = 1
) -> None:
"""Read until at least count frames of msg_type have been received."""
loop = asyncio.get_running_loop()
async def _read_loop() -> None:
while self.frame_counts[msg_type] < count:
data = await loop.sock_recv(self._sock, _READ_CHUNK)
assert data, "server closed the connection unexpectedly"
self.bytes_received += len(data)
self.frame_counts.update(self._parser.feed(data))
await self._recv_once()
await asyncio.wait_for(_read_loop(), timeout)
async def read_frame(self, msg_type: int, timeout: float = 10.0) -> bytes:
"""Return the oldest unconsumed payload of msg_type, skipping earlier frames of other types."""
frames = self._parser.frames
async def _read_loop() -> bytes:
while True:
while self._read_pos < len(frames):
frame_type, payload = frames[self._read_pos]
self._read_pos += 1
if frame_type == msg_type:
return payload
await self._recv_once()
return await asyncio.wait_for(_read_loop(), timeout)
def frames(self, msg_type: int) -> list[bytes]:
"""Payloads of every frame of msg_type received so far."""
return [payload for kind, payload in self._parser.frames if kind == msg_type]
@@ -0,0 +1,109 @@
"""End-to-end test of `store_yaml` recovery over the native API.
Uses the raw client so a test controls when it reads; aioesphomeapi has no get_yaml helper.
"""
from __future__ import annotations
import asyncio
import random
from aioesphomeapi import api_pb2
import pytest
from esphome.components.store_yaml import unpack_envelope
from esphome.helpers import zstd_module
from esphome.yaml_util import find_secret_references
from .log_utils import LineWaiter
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
from .types import RunCompiledFunction
GET_YAML_RESPONSE = MESSAGE_TYPE_OF[api_pb2.GetYamlResponse]
async def _read_yaml_stream(client: RawApiClient) -> list[api_pb2.GetYamlResponse]:
"""Read GetYamlResponse chunks up to and including the one marked done."""
responses: list[api_pb2.GetYamlResponse] = []
while not responses or not responses[-1].done:
payload = await client.read_frame(GET_YAML_RESPONSE)
responses.append(api_pb2.GetYamlResponse.FromString(payload))
return responses
@pytest.mark.asyncio
async def test_store_yaml_recovery(
yaml_config: str,
run_compiled: RunCompiledFunction,
unused_tcp_port: int,
) -> None:
"""Stream the embedded YAML back from a host build and check the recovered files."""
async with run_compiled(yaml_config), RawApiClient(unused_tcp_port) as client:
await client.connect("store_yaml integration test")
# Clients learn the device can answer get_yaml from its capabilities.
await client.send_message(api_pb2.DeviceCapabilitiesRequest())
capabilities = api_pb2.DeviceCapabilitiesResponse.FromString(
await client.read_frame(MESSAGE_TYPE_OF[api_pb2.DeviceCapabilitiesResponse])
)
assert capabilities.store_yaml.supported
await client.send_message(api_pb2.GetYamlRequest())
responses = await _read_yaml_stream(client)
compressed = b"".join(r.data for r in responses)
assert responses[0].total_size == len(compressed)
envelope = zstd_module().decompress(compressed)
files = unpack_envelope(envelope)
combined = b"\n".join(files.values())
assert b"store-yaml-test" in combined
assert b"store_yaml:" in combined
# The inline OTA password comes back as a !secret listed in the skeleton.
assert b"recoverme123" not in envelope
assert "ota_password" in find_secret_references(combined.decode())
assert b"ota_password: ''" in files["secrets.yaml"]
# Random hex barely compresses, so the blob spans many chunks and outgrows both buffers
SERVER_SNDBUF = 8192
RECV_BUFFER_SIZE = 4096
CHUNK_LIMIT = 1390
@pytest.mark.asyncio
async def test_store_yaml_recovery_backpressure(
yaml_config: str,
run_compiled: RunCompiledFunction,
unused_tcp_port: int,
) -> None:
"""A client that stops reading mid-stream still recovers every byte once it drains."""
rng = random.Random(16445)
values = {f"blob_{i}": rng.randbytes(60).hex() for i in range(1000)}
config = yaml_config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF)).replace(
"# GENERATED_SUBSTITUTIONS",
"\n".join(f" {name}: {value}" for name, value in values.items()),
)
log = LineWaiter()
async with (
run_compiled(config, line_callback=log.callback),
RawApiClient(unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE) as client,
):
# Without the pin the kernel buffers could swallow the stall
await log.wait_for("SO_SNDBUF pinned to", timeout=10.0)
await client.connect("store_yaml backpressure test")
await client.send_message(api_pb2.GetYamlRequest())
# Stop reading so the device fills both buffers and has to resume later
await asyncio.sleep(0.5)
responses = await _read_yaml_stream(client)
chunks = [r.data for r in responses]
compressed = b"".join(chunks)
assert len(compressed) > 4 * (SERVER_SNDBUF + RECV_BUFFER_SIZE)
assert len(responses) > 1 and all(len(c) <= CHUNK_LIMIT for c in chunks)
assert not any(r.done for r in responses[:-1])
assert responses[0].total_size == len(compressed)
combined = b"\n".join(
unpack_envelope(zstd_module().decompress(compressed)).values()
)
assert all(value.encode() in combined for value in values.values())
File diff suppressed because it is too large. Load diff