Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter

# Conflicts:
#	esphome/platformio/library.py
This commit is contained in:
J. Nick Koston
2026-08-23 18:14:53 -05:00
35 changed files with 987 additions and 270 deletions
+3 -2
View File
@@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# api must run its to_code to define USE_API, USE_API_PLAINTEXT,
# and add the noise-c library dependency.
# api must run its to_code to define USE_API and USE_API_NOISE. The
# AUTO_LOADed noise component runs its own to_code via the override in
# tests/benchmarks/components/noise/__init__.py.
manifest.enable_codegen()
original_to_code = manifest.to_code
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the api benchmark sources need.
manifest.enable_codegen()
@@ -0,0 +1,37 @@
"""Tests for the shared noise encryption key helpers."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.noise import decode_encryption_key, validate_encryption_key
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
def test_validate_encryption_key_roundtrips() -> None:
assert validate_encryption_key(KEY) == KEY
@pytest.mark.parametrize("value", ["not-base64!!!", "AAECAw=="])
def test_validate_encryption_key_rejects_bad_input(value: str) -> None:
with pytest.raises(cv.Invalid):
validate_encryption_key(value)
def test_decode_encryption_key_returns_32_bytes() -> None:
assert decode_encryption_key(KEY) == bytes(range(32))
def test_decode_encryption_key_rejects_invalid_base64() -> None:
"""The shared helper raises cv.Invalid, not binascii.Error."""
with pytest.raises(cv.Invalid, match="base64"):
decode_encryption_key("A")
def test_decode_encryption_key_rejects_short_decode() -> None:
"""a2b_base64 stops at embedded padding; a short decode must not become
a zero padded PSK on the device."""
with pytest.raises(cv.Invalid, match="32 bytes"):
decode_encryption_key("AAECAw==")
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the component sources under test need.
manifest.enable_codegen()
+1
View File
@@ -0,0 +1 @@
noise:
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
+2
View File
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,199 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_handshake.h"
namespace esphome::noise::testing {
using Action = NoiseResponderHandshake::Action;
// A raw noise-c initiator driving the same Noise_NNpsk0_25519_ChaChaPoly_SHA256
// pattern the responder class implements, so the tests exercise a real
// two-message handshake rather than mirrored calls into the class under test.
class Initiator {
public:
Initiator(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
EXPECT_EQ(noise_handshakestate_new_by_id(&this->state_, &nid, NOISE_ROLE_INITIATOR), 0);
EXPECT_EQ(noise_handshakestate_set_pre_shared_key(this->state_, psk.data(), psk.size()), 0);
EXPECT_EQ(noise_handshakestate_set_prologue(this->state_, prologue, prologue_len), 0);
EXPECT_EQ(noise_handshakestate_start(this->state_), 0);
}
~Initiator() {
if (this->state_ != nullptr)
noise_handshakestate_free(this->state_);
if (this->send_ != nullptr)
noise_cipherstate_free(this->send_);
if (this->recv_ != nullptr)
noise_cipherstate_free(this->recv_);
}
Initiator(const Initiator &) = delete;
Initiator &operator=(const Initiator &) = delete;
size_t write_message(uint8_t *out, size_t capacity) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, out, capacity);
EXPECT_EQ(noise_handshakestate_write_message(this->state_, &mbuf, nullptr), 0);
return mbuf.size;
}
int read_message(uint8_t *data, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, data, len);
return noise_handshakestate_read_message(this->state_, &mbuf, nullptr);
}
void split() { EXPECT_EQ(noise_handshakestate_split(this->state_, &this->send_, &this->recv_), 0); }
NoiseCipherState *send_{nullptr};
NoiseCipherState *recv_{nullptr};
private:
NoiseHandshakeState *state_{nullptr};
};
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
static psk_t make_psk(uint8_t seed) {
psk_t psk;
for (size_t i = 0; i < psk.size(); i++) {
psk[i] = static_cast<uint8_t>(seed + i);
}
return psk;
}
TEST(NoiseResponderHandshakeTest, ActionFailedBeforeInit) {
NoiseResponderHandshake handshake;
EXPECT_EQ(handshake.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
// The class doc promises a noise-c error, not a crash, when the message
// methods run outside their action() step; pin the library's null check
NoiseResponderHandshake handshake;
uint8_t buf[MAX_HANDSHAKE_SIZE] = {};
size_t out_len = 0;
EXPECT_NE(handshake.read_message(buf, sizeof(buf)), 0);
EXPECT_NE(handshake.write_message(buf, sizeof(buf), out_len), 0);
// Deliberately non-null: split() documents a nullptr postcondition on
// error, so a caller's uninitialized locals never hold garbage to free
auto *sentinel = reinterpret_cast<NoiseCipherState *>(0x1);
NoiseCipherState *send_cipher = sentinel;
NoiseCipherState *recv_cipher = sentinel;
EXPECT_NE(handshake.split(send_cipher, recv_cipher), 0);
EXPECT_EQ(send_cipher, nullptr);
EXPECT_EQ(recv_cipher, nullptr);
}
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_WRITE);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
ASSERT_GT(reply_len, 0u);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
initiator.split();
NoiseCipherState *send_cipher = nullptr;
NoiseCipherState *recv_cipher = nullptr;
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
ASSERT_NE(send_cipher, nullptr);
ASSERT_NE(recv_cipher, nullptr);
// The handshake state is released by split(); the class reports FAILED after
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
EXPECT_EQ(static_cast<size_t>(noise_cipherstate_get_mac_length(send_cipher)), MAC_SIZE);
// Responder encrypts, initiator decrypts
uint8_t frame[64];
static constexpr char PLAINTEXT[] = "encrypted ota";
std::memcpy(frame, PLAINTEXT, sizeof(PLAINTEXT));
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame, sizeof(PLAINTEXT), sizeof(frame));
ASSERT_EQ(noise_cipherstate_encrypt(send_cipher, &mbuf), 0);
EXPECT_EQ(mbuf.size, sizeof(PLAINTEXT) + MAC_SIZE);
noise_buffer_set_inout(mbuf, frame, mbuf.size, sizeof(frame));
ASSERT_EQ(noise_cipherstate_decrypt(initiator.recv_, &mbuf), 0);
ASSERT_EQ(mbuf.size, sizeof(PLAINTEXT));
EXPECT_EQ(std::memcmp(frame, PLAINTEXT, sizeof(PLAINTEXT)), 0);
noise_cipherstate_free(send_cipher);
noise_cipherstate_free(recv_cipher);
}
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// The documented retry shape: a repeated init() frees the previous state
// and starts over. The first message under the new key authenticating
// proves the restart took effect; the old state surviving would fail the
// MAC here.
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), 0);
}
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
int err = responder.read_message(msg, msg_len);
EXPECT_EQ(err, NOISE_ERROR_MAC_FAILURE);
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
// The prologue binds the plaintext preamble for downgrade resistance; a
// tampered preamble must fail even with the right key.
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
static const uint8_t TAMPERED[] = {'x'};
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), NOISE_ERROR_MAC_FAILURE);
}
} // namespace esphome::noise::testing
@@ -0,0 +1,74 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
namespace esphome::noise::testing {
TEST(NoiseContextTest, AllZerosPskIsReserved) {
psk_t zeros{};
EXPECT_TRUE(NoiseContext::is_all_zeros(zeros));
psk_t psk{};
psk[31] = 1;
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
NoiseContext ctx;
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(zeros);
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(psk);
EXPECT_TRUE(ctx.has_psk());
EXPECT_EQ(ctx.get_psk(), psk);
}
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
uint8_t header[FRAME_HEADER_SIZE];
write_frame_header(header, 0x1234);
EXPECT_EQ(header[0], FRAME_INDICATOR);
EXPECT_EQ(header[1], 0x12);
EXPECT_EQ(header[2], 0x34);
}
TEST(WireFormatTest, RejectPayloadCarriesStatusByteAndMacFailureContract) {
// The MAC failure string is a wire contract: clients match it to report a
// wrong key. Format the payload exactly the way the handshake read path does.
uint8_t buf[64];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
static constexpr char EXPECTED[] = "Handshake MAC failure";
ASSERT_EQ(len, 1 + strlen(EXPECTED));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, EXPECTED, strlen(EXPECTED)), 0);
// The exported floor covers the full MAC failure payload exactly
EXPECT_EQ(MAC_FAILURE_PAYLOAD_SIZE, 1 + strlen(EXPECTED));
// Any other error maps to the generic reason
len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_INVALID_STATE));
static constexpr char GENERIC[] = "Handshake error";
ASSERT_EQ(len, 1 + strlen(GENERIC));
EXPECT_EQ(memcmp(buf + 1, GENERIC, strlen(GENERIC)), 0);
}
TEST(WireFormatTest, RejectPayloadTruncatesToCapacity) {
uint8_t buf[8];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, sizeof(buf));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, "Handsha", 7), 0);
// A one-byte buffer still carries the status byte
uint8_t tiny[1];
len = format_reject_payload(tiny, sizeof(tiny), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, 1u);
EXPECT_EQ(tiny[0], HANDSHAKE_STATUS_REJECT);
// A zero-capacity buffer yields no payload and stays untouched
uint8_t none[1] = {0xAA};
EXPECT_EQ(format_reject_payload(none, 0, reject_reason_for(NOISE_ERROR_MAC_FAILURE)), 0u);
EXPECT_EQ(none[0], 0xAA);
}
} // namespace esphome::noise::testing
+13
View File
@@ -294,6 +294,19 @@ def test_generate_cmakelists_txt_multi_token_flag(tmp_component):
assert ' "-include"\n "cp_custom_alloc.h"\n' in content
def test_generate_cmakelists_txt_escapes_embedded_quotes(tmp_component):
"""A define value carrying a literal quote survives into CMake as an
escaped quote, not a prematurely-terminated string."""
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
# shlex keeps the backslash-escaped quotes as literal characters
tmp_component.data = {"build": {"flags": ['-DMSG=\\"hi\\"']}}
content = generate_cmakelists_txt(tmp_component)
assert '"-DMSG=\\"hi\\""' in content
def test_generate_cmakelists_txt_extra_script_link_flags(tmp_component):
"""Captured extra-script LINKFLAGS come out as target_link_options, not
compile options where they would be silently ineffective."""
@@ -461,6 +461,27 @@ def test_prepend_inserts_ahead_of_existing(method: str) -> None:
assert env.result.libs == ["algobsec", "bsec", "m"]
def test_env_membership_and_iteration(tmp_path) -> None:
"""Membership tests and for-loops must use the mapping protocol; the
legacy sequence fallback through __getitem__ would loop forever."""
env = _FakeSConsEnv(
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
)
assert "BOARD_MCU" in env
assert "NOPE" not in env
assert sorted(env) == ["BOARD_MCU", "PIOENV", "PIOPLATFORM"]
def test_apply_extra_script_non_string_falsey_raises(tmp_path) -> None:
"""A falsey non-string extraScript (false, 0, []) is a malformed
manifest, not an absent script."""
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": False}}
with pytest.raises(EsphomeError, match="must be a string"):
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
def test_env_get_unknown_key_warns_once(caplog) -> None:
"""A script branching on an unmodelled env var is diagnosable."""
env = _FakeSConsEnv(
+19 -1
View File
@@ -800,10 +800,28 @@ def test_normalize_dependencies_forms(caplog) -> None:
assert normalize_dependencies({"Foo": ["1.0", "2.0"]}, "libx") == []
assert normalize_dependencies([{"name": "Foo", "version": 1}], "libx") == []
assert caplog.text.count("unrecognized dependency entry") == 7
# A non-string owner would stringify into a malformed registry name
assert (
normalize_dependencies(
[{"name": "Foo", "owner": {"bad": 1}, "version": "1.0"}], "libx"
)
== []
)
# A falsey scalar (0, false) is malformed, not an empty list
assert normalize_dependencies(0, "libx") == []
assert "Ignoring unrecognized dependencies 0 of libx" in caplog.text
@pytest.mark.parametrize(
"manifest", [["not", "a", "manifest"], {"name": "A", "build": "src"}]
"manifest",
[
["not", "a", "manifest"],
{"name": "A", "build": "src"},
{"name": "A", "ESPHOME": "yes"},
{"name": "A", "build": {"srcDir": 123}},
{"name": "A", "build": {"includeDir": ["inc"]}},
{"name": "A", "build": {"srcFilter": {"+": "src"}}},
],
)
def test_convert_libraries_malformed_manifest_raises(
tmp_path, monkeypatch, manifest
+16
View File
@@ -66,6 +66,22 @@ def test_generate_cmakelists_txt_flags_and_includes(tmp_path):
assert "-lm" in out
def test_generate_cmakelists_txt_lexes_spaced_flags(tmp_path):
"""A spaced -I entry routes to include dirs instead of landing verbatim
in compile options; same shared lexer as the espidf emitter."""
c = _make_component(tmp_path)
(tmp_path / "src").mkdir()
(tmp_path / "src" / "a.c").write_text("")
(tmp_path / "include").mkdir()
c.data = {"build": {"flags": "-I include -DBAR=1"}}
out = generate_cmakelists_txt(c)
assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out
assert "-DBAR=1" in out
assert "-I include" not in out
def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch):
# Two converted libraries: one top-level, one transitive dependency. The
# converter calls backend.emit for both; generate_zephyr_modules must return