mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
1301 lines
46 KiB
Python
1301 lines
46 KiB
Python
"""Unit tests for encrypted OTA uploads in esphome.espota2.
|
|
|
|
A fake device implementing the responder side of the wire protocol (via
|
|
noiseprotocol, which esphome already has through aioesphomeapi) serves a real
|
|
TCP loopback connection, so these exercise the actual handshake, framing, and
|
|
cipher interop of the client code. Tests that need the client-side crypto skip
|
|
when the installed aioesphomeapi predates the noise module.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from collections.abc import Callable
|
|
import hashlib
|
|
import io
|
|
import itertools
|
|
import logging
|
|
from pathlib import Path
|
|
import socket
|
|
import sys
|
|
import threading
|
|
from typing import Any
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from esphome import espota2
|
|
from esphome.core import EsphomeError
|
|
from esphome.espota2 import ProbeResult
|
|
|
|
PSK = base64.b64encode(bytes(range(32))).decode()
|
|
OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode()
|
|
|
|
MAGIC = bytes(espota2.MAGIC_BYTES)
|
|
# One resolved loopback address, as resolve_ip_address returns it
|
|
RESOLVED = [(2, 1, 0, "", ("127.0.0.1", 1))]
|
|
TWO_ADDRESSES = [(2, 1, 0, "", ("10.0.0.1", 1)), (2, 1, 0, "", ("10.0.0.2", 1))]
|
|
|
|
|
|
def _recv_exact(sock: socket.socket, amount: int) -> bytes:
|
|
data = b""
|
|
while len(data) < amount:
|
|
chunk = sock.recv(amount - len(data))
|
|
if not chunk:
|
|
raise ConnectionError("client closed")
|
|
data += chunk
|
|
return data
|
|
|
|
|
|
def _frame(payload: bytes) -> bytes:
|
|
return (
|
|
bytes([espota2.NOISE_FRAME_INDICATOR, len(payload) >> 8, len(payload) & 0xFF])
|
|
+ payload
|
|
)
|
|
|
|
|
|
def _send_frame(sock: socket.socket, payload: bytes) -> None:
|
|
sock.sendall(_frame(payload))
|
|
|
|
|
|
def _recv_frame(sock: socket.socket) -> bytes:
|
|
header = _recv_exact(sock, 3)
|
|
assert header[0] == 0x01
|
|
return _recv_exact(sock, (header[1] << 8) | header[2])
|
|
|
|
|
|
class FakeEncryptedDevice(threading.Thread):
|
|
"""Responder side of the encrypted OTA wire protocol."""
|
|
|
|
def __init__(
|
|
self,
|
|
psk: str = PSK,
|
|
version: int = 2,
|
|
offer_noise: bool = True,
|
|
require_noise: bool = True,
|
|
prologue_features_override: int | None = None,
|
|
connections: int = 1,
|
|
drop_handshakes: int = 0,
|
|
reject_reason: str | None = None,
|
|
) -> None:
|
|
super().__init__(daemon=True)
|
|
self.connections = connections
|
|
self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times
|
|
self.reject_reason = reject_reason # refuse every handshake with this reason
|
|
self.psk = psk
|
|
self.version = version
|
|
self.offer_noise = offer_noise
|
|
self.require_noise = require_noise
|
|
self.prologue_features_override = prologue_features_override
|
|
self.received: bytes | None = None
|
|
self.probes = 0 # clients that left right after the handshake
|
|
self.error: Exception | None = None
|
|
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.listener.bind(("127.0.0.1", 0))
|
|
self.listener.listen(1)
|
|
self.port = self.listener.getsockname()[1]
|
|
|
|
def run(self) -> None:
|
|
try:
|
|
for _ in range(self.connections):
|
|
sock, _ = self.listener.accept()
|
|
sock.settimeout(10)
|
|
with sock:
|
|
self._serve(sock)
|
|
except Exception as err: # noqa: BLE001 - surfaced via join_and_check
|
|
self.error = err
|
|
finally:
|
|
self.listener.close()
|
|
|
|
def join_and_check(self) -> None:
|
|
self.join(timeout=10)
|
|
assert not self.is_alive(), "fake device did not finish"
|
|
if self.error is not None:
|
|
raise self.error
|
|
|
|
def _serve(self, sock: socket.socket) -> None:
|
|
assert _recv_exact(sock, 5) == MAGIC
|
|
sock.sendall(bytes([espota2.RESPONSE_OK, self.version]))
|
|
features = _recv_exact(sock, 1)[0]
|
|
noise_negotiated = bool(
|
|
features & espota2.CLIENT_FEATURE_SUPPORTS_NOISE
|
|
and features & espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
|
|
)
|
|
if self.require_noise and not noise_negotiated:
|
|
sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED]))
|
|
return
|
|
server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0
|
|
sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags]))
|
|
if not (noise_negotiated and self.offer_noise):
|
|
# A device that does not require encryption continues in
|
|
# plaintext whatever the client asked for, like older firmware
|
|
try:
|
|
self._transfer(
|
|
lambda byte: sock.sendall(bytes([byte])),
|
|
lambda length: _recv_exact(sock, length),
|
|
lambda remaining: _recv_exact(
|
|
sock, min(remaining, espota2.UPLOAD_BLOCK_SIZE)
|
|
),
|
|
)
|
|
except ConnectionError:
|
|
# A keyed client without fallback fails closed and hangs up
|
|
if noise_negotiated and not self.offer_noise:
|
|
return
|
|
raise
|
|
return
|
|
|
|
from cryptography.exceptions import InvalidTag
|
|
from noise.connection import NoiseConnection
|
|
|
|
prologue_features = (
|
|
features
|
|
if self.prologue_features_override is None
|
|
else self.prologue_features_override
|
|
)
|
|
prologue = (
|
|
espota2.NOISE_PROLOGUE_INIT
|
|
+ MAGIC
|
|
+ bytes([espota2.RESPONSE_OK, self.version, prologue_features])
|
|
+ bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])
|
|
)
|
|
proto = NoiseConnection.from_name(b"Noise_NNpsk0_25519_ChaChaPoly_SHA256")
|
|
proto.set_as_responder()
|
|
proto.set_psks(base64.b64decode(self.psk))
|
|
proto.set_prologue(prologue)
|
|
proto.start_handshake()
|
|
|
|
msg1 = _recv_frame(sock)
|
|
assert msg1[0] == 0x00
|
|
if self.drop_handshakes > 0:
|
|
self.drop_handshakes -= 1
|
|
return # a transport fault: the socket closes with no reply
|
|
if self.reject_reason is not None:
|
|
_send_frame(sock, b"\x01" + self.reject_reason.encode())
|
|
return
|
|
try:
|
|
proto.read_message(msg1[1:])
|
|
except InvalidTag:
|
|
_send_frame(sock, b"\x01" + espota2.NOISE_MAC_FAILURE_REASON.encode())
|
|
return
|
|
_send_frame(sock, b"\x00" + bytes(proto.write_message()))
|
|
|
|
def send_byte(byte: int) -> None:
|
|
_send_frame(sock, proto.encrypt(bytes([byte])))
|
|
|
|
def recv_unit(length: int) -> bytes:
|
|
plaintext = proto.decrypt(_recv_frame(sock))
|
|
assert len(plaintext) == length, "control units must be one per frame"
|
|
return plaintext
|
|
|
|
def recv_data(_remaining: int) -> bytes:
|
|
plaintext = proto.decrypt(_recv_frame(sock))
|
|
assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT
|
|
return plaintext
|
|
|
|
self._transfer(send_byte, recv_unit, recv_data)
|
|
|
|
def _transfer(
|
|
self,
|
|
send_byte: Callable[[int], None],
|
|
recv_unit: Callable[[int], bytes],
|
|
recv_data: Callable[[int], bytes],
|
|
) -> None:
|
|
"""The post-handshake exchange, identical over both transports."""
|
|
send_byte(espota2.RESPONSE_AUTH_OK)
|
|
try:
|
|
recv_unit(1) # ota type
|
|
except ConnectionError:
|
|
# A key probe leaves here, like the firmware it is patterned on
|
|
self.probes += 1
|
|
return
|
|
size = int.from_bytes(recv_unit(4), "big")
|
|
send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK)
|
|
md5_hex = recv_unit(32)
|
|
send_byte(espota2.RESPONSE_BIN_MD5_OK)
|
|
|
|
received = b""
|
|
acked = 0
|
|
while len(received) < size:
|
|
received += recv_data(size - len(received))
|
|
if self.version >= espota2.OTA_VERSION_2_0:
|
|
while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or (
|
|
len(received) == size and acked < size
|
|
):
|
|
send_byte(espota2.RESPONSE_CHUNK_OK)
|
|
acked += espota2.UPLOAD_BLOCK_SIZE
|
|
assert hashlib.md5(received).hexdigest().encode() == md5_hex
|
|
send_byte(espota2.RESPONSE_RECEIVE_OK)
|
|
send_byte(espota2.RESPONSE_UPDATE_END_OK)
|
|
assert recv_unit(1) == bytes([espota2.RESPONSE_OK])
|
|
self.received = received
|
|
|
|
|
|
def _upload(
|
|
device: FakeEncryptedDevice,
|
|
firmware: bytes,
|
|
noise_psk: str | None,
|
|
plaintext_fallback: bool = False,
|
|
allow_plaintext_upload: bool = False,
|
|
) -> None:
|
|
device.start()
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(10)
|
|
sock.connect(("127.0.0.1", device.port))
|
|
try:
|
|
espota2.perform_ota(
|
|
sock,
|
|
None,
|
|
io.BytesIO(firmware),
|
|
Path("firmware.bin"),
|
|
noise_psk=noise_psk,
|
|
plaintext_fallback=plaintext_fallback,
|
|
allow_plaintext_upload=allow_plaintext_upload,
|
|
)
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def _run_ota(
|
|
device: FakeEncryptedDevice,
|
|
firmware: bytes,
|
|
tmp_path: Path,
|
|
noise_psk: str,
|
|
plaintext_fallback: bool = True,
|
|
old_noise_psk: str | None = None,
|
|
**kwargs: Any,
|
|
) -> int:
|
|
"""Drive the retry loop, which is where the fallback and the old key reconnect."""
|
|
path = tmp_path / "firmware.bin"
|
|
path.write_bytes(firmware)
|
|
device.start()
|
|
rc, _ = espota2.run_ota(
|
|
"127.0.0.1",
|
|
device.port,
|
|
None,
|
|
path,
|
|
noise_psk=noise_psk,
|
|
plaintext_fallback=plaintext_fallback,
|
|
old_noise_psk=old_noise_psk,
|
|
**kwargs,
|
|
)
|
|
return rc
|
|
|
|
|
|
def test_on_upload_start_runs_once_the_device_accepted_the_session(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The rejected first key never reaches the hook; the old_key reconnect does."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(psk=OTHER_PSK, connections=2)
|
|
on_upload_start = Mock()
|
|
with patch("time.sleep"):
|
|
rc = _run_ota(
|
|
device,
|
|
b"firmware",
|
|
tmp_path,
|
|
PSK,
|
|
plaintext_fallback=False,
|
|
old_noise_psk=OTHER_PSK,
|
|
on_upload_start=on_upload_start,
|
|
)
|
|
device.join_and_check()
|
|
assert rc == 0
|
|
on_upload_start.assert_called_once()
|
|
|
|
|
|
def test_on_upload_start_fires_once_across_retries() -> None:
|
|
"""A data phase fault after the hook retries the upload; the hook has
|
|
already reported that the device may commit."""
|
|
calls: list[int] = []
|
|
|
|
def perform(*_args: Any, **kwargs: Any) -> None:
|
|
kwargs["on_upload_start"]()
|
|
if len(calls) == 0:
|
|
calls.append(1)
|
|
raise espota2.OTANetworkError("reset")
|
|
|
|
hook = Mock()
|
|
with (
|
|
patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED),
|
|
patch("socket.socket"),
|
|
patch("pathlib.Path.open"),
|
|
patch("esphome.espota2.perform_ota", side_effect=perform),
|
|
patch("time.sleep"),
|
|
):
|
|
rc, _ = espota2.run_ota_impl_(
|
|
"h", 1, None, Path("fw.bin"), on_upload_start=hook
|
|
)
|
|
assert rc == 0
|
|
hook.assert_called_once()
|
|
|
|
|
|
def test_probe_result_is_true_only_when_accepted() -> None:
|
|
assert ProbeResult.ACCEPTED
|
|
assert not any(r for r in ProbeResult if r is not ProbeResult.ACCEPTED)
|
|
|
|
|
|
def test_probe_ota_key_keeps_the_key_answer_over_a_stale_device_error(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A stale address running some other listener answers with its own
|
|
error; what the device said about the key still wins."""
|
|
with (
|
|
patch("esphome.espota2.resolve_ip_address", return_value=TWO_ADDRESSES),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=[
|
|
espota2.OTAKeyRejected("no"),
|
|
espota2.OTAError("unsupported OTA version 9"),
|
|
],
|
|
),
|
|
patch("time.sleep"),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False)
|
|
is ProbeResult.REJECTED
|
|
)
|
|
assert "failed, rejected: 10.0.0.1: no" in caplog.text
|
|
|
|
|
|
def test_on_upload_start_failure_closes_the_socket() -> None:
|
|
def start_upload(*_args: Any, **kwargs: Any) -> None:
|
|
kwargs["on_upload_start"]()
|
|
|
|
with (
|
|
patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED),
|
|
patch("socket.socket") as sock,
|
|
patch("pathlib.Path.open"),
|
|
patch("esphome.espota2.perform_ota", side_effect=start_upload),
|
|
pytest.raises(RuntimeError, match="boom"),
|
|
):
|
|
espota2.run_ota_impl_(
|
|
"h",
|
|
1,
|
|
None,
|
|
Path("fw.bin"),
|
|
on_upload_start=Mock(side_effect=RuntimeError("boom")),
|
|
)
|
|
sock.return_value.close.assert_called_once()
|
|
|
|
|
|
THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("device_psk", "old_noise_psk", "expected_rc", "retried"),
|
|
[
|
|
# The device still runs the previous key: one reconnect with old_key
|
|
(OTHER_PSK, OTHER_PSK, 0, True),
|
|
# The device already runs the new key: old_key is never presented
|
|
(PSK, OTHER_PSK, 0, False),
|
|
# Neither key matches: the retry is spent, then it fails closed
|
|
(THIRD_PSK, OTHER_PSK, 1, True),
|
|
# No old_key configured: a rejected key is a device error
|
|
(OTHER_PSK, None, 1, False),
|
|
],
|
|
ids=["old_key_accepted", "key_accepted", "both_rejected", "no_old_key"],
|
|
)
|
|
def test_old_key_retry(
|
|
caplog: pytest.LogCaptureFixture,
|
|
tmp_path: Path,
|
|
device_psk: str,
|
|
old_noise_psk: str | None,
|
|
expected_rc: int,
|
|
retried: bool,
|
|
) -> None:
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
firmware = b"firmware"
|
|
device = FakeEncryptedDevice(psk=device_psk, connections=2 if retried else 1)
|
|
with patch("time.sleep"), caplog.at_level(logging.WARNING):
|
|
rc = _run_ota(
|
|
device,
|
|
firmware,
|
|
tmp_path,
|
|
PSK,
|
|
plaintext_fallback=False,
|
|
old_noise_psk=old_noise_psk,
|
|
)
|
|
device.join_and_check()
|
|
assert rc == expected_rc
|
|
assert (device.received == firmware) is (expected_rc == 0)
|
|
assert (
|
|
any("retrying with 'old_key'" in r.message for r in caplog.records) is retried
|
|
)
|
|
assert any("accepted 'old_key'" in r.message for r in caplog.records) is (
|
|
retried and expected_rc == 0
|
|
)
|
|
assert not any("plaintext" in r.message for r in caplog.records)
|
|
if expected_rc == 1 and old_noise_psk is not None:
|
|
assert any("rejected both" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_encrypted_upload_success() -> None:
|
|
"""A full encrypted v2 upload spanning several 8192-byte blocks."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
firmware = bytes(range(256)) * 80 # 20480 bytes, crosses chunk-ack boundaries
|
|
device = FakeEncryptedDevice()
|
|
with patch("time.sleep"):
|
|
_upload(device, firmware, PSK)
|
|
device.join_and_check()
|
|
assert device.received == firmware
|
|
|
|
|
|
def test_encrypted_upload_version_1() -> None:
|
|
"""Version 1 protocol (no chunk acks) works through the noise transport."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
firmware = b"v1 firmware image" * 100
|
|
device = FakeEncryptedDevice(version=1)
|
|
with patch("time.sleep"):
|
|
_upload(device, firmware, PSK)
|
|
device.join_and_check()
|
|
assert device.received == firmware
|
|
|
|
|
|
def test_wrong_key_fails_with_clear_error() -> None:
|
|
"""A key mismatch surfaces the device's handshake reject readably."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(psk=OTHER_PSK)
|
|
with pytest.raises(espota2.OTAError, match="encryption key correct"):
|
|
_upload(device, b"firmware", PSK)
|
|
device.join_and_check()
|
|
|
|
|
|
def test_tampered_negotiation_breaks_handshake() -> None:
|
|
"""A negotiation byte differing between the sides breaks the prologue MAC."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(
|
|
prologue_features_override=espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
|
|
)
|
|
with pytest.raises(espota2.OTAError, match="encryption key correct"):
|
|
_upload(device, b"firmware", PSK)
|
|
device.join_and_check()
|
|
|
|
|
|
def test_client_fails_closed_when_device_lacks_encryption() -> None:
|
|
"""With a key configured, a device not offering noise aborts the upload."""
|
|
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
|
|
with pytest.raises(espota2.OTAEncryptionNotOffered, match="did not offer"):
|
|
_upload(device, b"firmware", PSK)
|
|
device.join_and_check()
|
|
|
|
|
|
def test_allow_plaintext_upload_when_device_does_not_offer(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The explicit opt in sends the image in plaintext to a device that
|
|
cannot encrypt, naming the option in the warning."""
|
|
firmware = b"firmware"
|
|
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
|
|
with patch("time.sleep"), caplog.at_level(logging.WARNING):
|
|
_upload(device, firmware, PSK, allow_plaintext_upload=True)
|
|
device.join_and_check()
|
|
assert device.received == firmware
|
|
assert any("'allow_plaintext_upload' is set" in r.message for r in caplog.records)
|
|
assert not any("2027.3.0" in r.message for r in caplog.records)
|
|
assert not any(
|
|
"Remove it from the configuration" in r.message for r in caplog.records
|
|
)
|
|
|
|
|
|
def test_allow_plaintext_upload_warns_once_device_encrypts(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The removal warning appears exactly when it is safe to act on: the
|
|
device offered encryption and accepted the key with the option still set."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
firmware = b"firmware"
|
|
device = FakeEncryptedDevice()
|
|
with caplog.at_level(logging.WARNING):
|
|
_upload(device, firmware, PSK, allow_plaintext_upload=True)
|
|
device.join_and_check()
|
|
assert device.received == firmware
|
|
assert any(
|
|
"Remove it from the configuration now" in r.message for r in caplog.records
|
|
)
|
|
with caplog.at_level(logging.WARNING):
|
|
caplog.clear()
|
|
_upload(FakeEncryptedDevice(), firmware, PSK)
|
|
assert not caplog.records
|
|
|
|
|
|
def test_allow_plaintext_upload_keeps_wrong_key_failing(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The opt in only covers a device that does not offer; a rejected key
|
|
never turns into a plaintext upload."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(psk=OTHER_PSK, require_noise=False)
|
|
with (
|
|
caplog.at_level(logging.WARNING),
|
|
pytest.raises(espota2.OTAError, match="encryption key correct"),
|
|
):
|
|
_upload(device, b"firmware", PSK, allow_plaintext_upload=True)
|
|
device.join_and_check()
|
|
assert device.received != b"firmware"
|
|
assert not any("plaintext" in r.message for r in caplog.records)
|
|
|
|
|
|
# Remove before 2027.3.0
|
|
def test_fallback_when_device_does_not_offer(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""The api key is tried opportunistically; an older device that cannot
|
|
encrypt still gets its update, with a warning."""
|
|
firmware = b"firmware"
|
|
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
|
|
with patch("time.sleep"), caplog.at_level(logging.WARNING):
|
|
_upload(device, firmware, PSK, plaintext_fallback=True)
|
|
device.join_and_check()
|
|
assert device.received == firmware
|
|
assert any("fallback is removed in 2027.3.0" in r.message for r in caplog.records)
|
|
|
|
|
|
# Remove before 2027.3.0
|
|
@pytest.mark.parametrize(
|
|
("device_kwargs", "expected_rc", "fell_back"),
|
|
[
|
|
# A wrong key against an offering device reconnects in plaintext
|
|
({"psk": OTHER_PSK, "require_noise": False, "connections": 2}, 0, True),
|
|
# The plaintext retry is refused by a device that requires encryption
|
|
({"psk": OTHER_PSK, "require_noise": True, "connections": 2}, 1, True),
|
|
# A dropped connection inside the handshake is retried encrypted
|
|
({"require_noise": False, "connections": 2, "drop_handshakes": 1}, 0, False),
|
|
# A second transport fault inside the handshake falls back
|
|
({"require_noise": False, "connections": 3, "drop_handshakes": 2}, 0, True),
|
|
],
|
|
ids=["wrong_key", "wrong_key_required", "one_fault", "two_faults"],
|
|
)
|
|
def test_fallback_through_the_retry_loop(
|
|
caplog: pytest.LogCaptureFixture,
|
|
tmp_path: Path,
|
|
device_kwargs: dict[str, Any],
|
|
expected_rc: int,
|
|
fell_back: bool,
|
|
) -> None:
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
firmware = b"firmware"
|
|
device = FakeEncryptedDevice(**device_kwargs)
|
|
with patch("time.sleep"), caplog.at_level(logging.WARNING):
|
|
rc = _run_ota(device, firmware, tmp_path, PSK)
|
|
device.join_and_check()
|
|
assert rc == expected_rc
|
|
assert (device.received == firmware) is (expected_rc == 0)
|
|
assert (
|
|
any("Retrying in plaintext" in r.message for r in caplog.records) is fell_back
|
|
)
|
|
if expected_rc == 1:
|
|
assert any("requires an encrypted OTA" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_plaintext_client_gets_encryption_required_error() -> None:
|
|
"""A client without a key gets the device's 0x94 error message."""
|
|
device = FakeEncryptedDevice()
|
|
with pytest.raises(espota2.OTAError, match="requires an encrypted OTA"):
|
|
_upload(device, b"firmware", None)
|
|
device.join_and_check()
|
|
|
|
|
|
def test_missing_aioesphomeapi_noise_module_message() -> None:
|
|
"""An aioesphomeapi without the noise module produces a clear error."""
|
|
with (
|
|
patch.dict(sys.modules, {"aioesphomeapi.noise": None}),
|
|
pytest.raises(espota2.OTAError, match="requires a newer aioesphomeapi"),
|
|
):
|
|
espota2.NoiseSocketWrapper(Mock(), PSK, b"prologue")
|
|
|
|
|
|
class ScriptedSocket:
|
|
"""Serves scripted recv chunks; b"" means the peer closed."""
|
|
|
|
def __init__(self, *chunks: bytes | Exception) -> None:
|
|
self.chunks = list(chunks)
|
|
self.sent: list[bytes] = []
|
|
|
|
def sendall(self, data: bytes) -> None:
|
|
self.sent.append(data)
|
|
|
|
def settimeout(self, timeout: float) -> None:
|
|
pass
|
|
|
|
def recv(self, amount: int) -> bytes:
|
|
if not self.chunks:
|
|
return b""
|
|
chunk = self.chunks[0]
|
|
if isinstance(chunk, Exception):
|
|
self.chunks.pop(0)
|
|
raise chunk
|
|
take, rest = chunk[:amount], chunk[amount:]
|
|
if rest:
|
|
self.chunks[0] = rest
|
|
else:
|
|
self.chunks.pop(0)
|
|
return take
|
|
|
|
|
|
def _wrapper(*chunks: bytes | Exception) -> espota2.NoiseSocketWrapper:
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
return espota2.NoiseSocketWrapper(ScriptedSocket(*chunks), PSK, b"prologue")
|
|
|
|
|
|
def test_wrapper_rejects_malformed_psk() -> None:
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
with pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"):
|
|
espota2.NoiseSocketWrapper(ScriptedSocket(), "not-base64!!!", b"prologue")
|
|
|
|
|
|
def test_handshake_socket_error_is_network_error() -> None:
|
|
wrapper = _wrapper(OSError("boom"))
|
|
with pytest.raises(espota2.OTANetworkError, match="noise handshake"):
|
|
wrapper.do_handshake()
|
|
|
|
|
|
def test_handshake_closed_at_frame_boundary() -> None:
|
|
wrapper = _wrapper()
|
|
with pytest.raises(espota2.OTANetworkError, match="closed connection during"):
|
|
wrapper.do_handshake()
|
|
|
|
|
|
def test_handshake_reject_with_other_reason() -> None:
|
|
wrapper = _wrapper(_frame(b"\x01Handshake error"))
|
|
with pytest.raises(
|
|
espota2.OTAError, match="rejected the noise handshake: Handshake error"
|
|
):
|
|
wrapper.do_handshake()
|
|
|
|
|
|
def test_handshake_garbage_second_message() -> None:
|
|
"""A valid-looking point with a garbage MAC is a key failure."""
|
|
wrapper = _wrapper(_frame(b"\x00" + bytes(range(48))))
|
|
with pytest.raises(
|
|
espota2.OTAKeyRejected, match="handshake failed; is the OTA encryption key"
|
|
):
|
|
wrapper.do_handshake()
|
|
|
|
|
|
def test_handshake_invalid_curve_point() -> None:
|
|
"""An all-zero x25519 point is a clean error, not a crash, and not a
|
|
key failure: it must not spend the old_key retry."""
|
|
wrapper = _wrapper(_frame(b"\x00" + bytes(48)))
|
|
with pytest.raises(espota2.OTAError, match="handshake failed: ") as info:
|
|
wrapper.do_handshake()
|
|
assert not isinstance(info.value, espota2.OTAKeyRejected)
|
|
|
|
|
|
def test_recv_closed_at_frame_boundary_returns_empty() -> None:
|
|
wrapper = _wrapper()
|
|
assert wrapper.recv(1) == b""
|
|
|
|
|
|
def test_recv_corrupt_frame_is_retryable_network_error() -> None:
|
|
from cryptography.exceptions import InvalidTag
|
|
|
|
wrapper = _wrapper(_frame(b"ciphertext"))
|
|
wrapper._decrypt = Mock(decrypt=Mock(side_effect=InvalidTag()))
|
|
with pytest.raises(espota2.OTANetworkError, match="decryption failed"):
|
|
wrapper.recv(1)
|
|
|
|
|
|
def test_wrapper_blocks_unencrypted_socket_methods() -> None:
|
|
"""Byte-moving socket methods must not bypass the encrypted transport."""
|
|
wrapper = _wrapper()
|
|
# The harmless socket controls pass through to the wrapped socket
|
|
wrapper._sock = Mock()
|
|
wrapper.settimeout(1)
|
|
wrapper._sock.settimeout.assert_called_once_with(1)
|
|
wrapper.setsockopt(6, 1, 1)
|
|
wrapper._sock.setsockopt.assert_called_once_with(6, 1, 1)
|
|
wrapper.close()
|
|
wrapper._sock.close.assert_called_once_with()
|
|
with pytest.raises(AttributeError):
|
|
_ = wrapper.send
|
|
with pytest.raises(AttributeError):
|
|
_ = wrapper.recv_into
|
|
|
|
|
|
def test_recv_empty_plaintext_frame_is_protocol_error() -> None:
|
|
"""A MAC-only frame decrypts to nothing; b'' from recv must mean close."""
|
|
wrapper = _wrapper(_frame(bytes(16)))
|
|
wrapper._decrypt = Mock(decrypt=Mock(return_value=b""))
|
|
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
|
|
wrapper.recv(1)
|
|
|
|
|
|
def test_recv_frame_bad_indicator_is_retryable() -> None:
|
|
wrapper = _wrapper(b"\x02\x00\x01x")
|
|
with pytest.raises(espota2.OTANetworkError, match="Bad noise frame indicator"):
|
|
wrapper._recv_frame()
|
|
|
|
|
|
def test_recv_frame_zero_length_is_retryable() -> None:
|
|
wrapper = _wrapper(bytes([espota2.NOISE_FRAME_INDICATOR, 0, 0]))
|
|
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
|
|
wrapper._recv_frame()
|
|
|
|
|
|
def test_perform_ota_blank_key_refuses_plaintext() -> None:
|
|
with pytest.raises(espota2.OTAError, match="empty OTA encryption key"):
|
|
espota2.perform_ota(
|
|
ScriptedSocket(), None, io.BytesIO(b"x"), Path("f.bin"), noise_psk=""
|
|
)
|
|
|
|
|
|
def test_recv_exact_closed_mid_frame() -> None:
|
|
wrapper = _wrapper(_frame(b"partial")[:5])
|
|
with pytest.raises(OSError, match="closed inside a noise frame"):
|
|
wrapper._recv_frame()
|
|
|
|
|
|
def test_recv_serves_buffered_plaintext_without_new_frame() -> None:
|
|
"""A second recv drains the decrypted buffer without reading another frame."""
|
|
wrapper = _wrapper(_frame(b"ciphertext"))
|
|
wrapper._decrypt = Mock(decrypt=Mock(return_value=b"AB"))
|
|
assert wrapper.recv(1) == b"A" # reads and decrypts one frame
|
|
assert wrapper.recv(1) == b"B" # served from the buffer, no new frame
|
|
wrapper._decrypt.decrypt.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize("plaintext_fallback", [False, True])
|
|
def test_non_key_reject_reason_is_a_device_error(
|
|
caplog: pytest.LogCaptureFixture, tmp_path: Path, plaintext_fallback: bool
|
|
) -> None:
|
|
"""Only a key failure spends the old_key retry or, until 2027.3.0, the
|
|
plaintext fallback; another reject reason fails at once."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(reject_reason="Busy")
|
|
with patch("time.sleep"), caplog.at_level(logging.WARNING):
|
|
rc = _run_ota(
|
|
device,
|
|
b"firmware",
|
|
tmp_path,
|
|
PSK,
|
|
plaintext_fallback=plaintext_fallback,
|
|
old_noise_psk=OTHER_PSK,
|
|
)
|
|
device.join_and_check()
|
|
assert rc == 1
|
|
assert not any("retrying with 'old_key'" in r.message for r in caplog.records)
|
|
assert not any("Retrying in plaintext" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_probe_ota_key_accepts_the_running_key(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The negotiation's upload-style progress lines stay below INFO."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice()
|
|
device.start()
|
|
with caplog.at_level(logging.INFO):
|
|
assert (
|
|
espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5)
|
|
is ProbeResult.ACCEPTED
|
|
)
|
|
device.join_and_check()
|
|
assert device.probes == 1
|
|
assert device.received is None
|
|
assert "accepted the key" in caplog.text
|
|
assert "Connection established" not in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"device_kwargs",
|
|
[{"psk": OTHER_PSK}, {"offer_noise": False, "require_noise": False}],
|
|
ids=["wrong_key", "no_offer"],
|
|
)
|
|
def test_probe_ota_key_gives_up_at_the_deadline(
|
|
device_kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(connections=2, **device_kwargs)
|
|
device.start()
|
|
with patch("time.sleep"), caplog.at_level(logging.WARNING):
|
|
assert (
|
|
espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=0.5)
|
|
is not ProbeResult.ACCEPTED
|
|
)
|
|
device.join_and_check()
|
|
assert any("Checking the key failed" in r.message for r in caplog.records)
|
|
assert device.received is None
|
|
|
|
|
|
def test_probe_ota_key_fails_fast_on_a_definitive_answer() -> None:
|
|
"""A device in steady state that rejects the key gives its final answer
|
|
on the first attempt; the precheck does not wait out the deadline."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(psk=OTHER_PSK)
|
|
device.start()
|
|
with patch("time.sleep") as sleep:
|
|
assert (
|
|
espota2.probe_ota_key(
|
|
"127.0.0.1", device.port, PSK, timeout=30, retry_rejected=False
|
|
)
|
|
is ProbeResult.REJECTED
|
|
)
|
|
device.join_and_check()
|
|
sleep.assert_not_called()
|
|
|
|
|
|
def test_probe_ota_key_retries_after_a_transport_fault() -> None:
|
|
"""The device may still be rebooting right after the upload."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice(connections=2, drop_handshakes=1)
|
|
device.start()
|
|
with patch("time.sleep"):
|
|
assert (
|
|
espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5)
|
|
is ProbeResult.ACCEPTED
|
|
)
|
|
device.join_and_check()
|
|
assert device.probes == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs",
|
|
[{"side_effect": EsphomeError("no such host")}, {"return_value": []}],
|
|
ids=["error", "empty"],
|
|
)
|
|
def test_probe_ota_key_resolution_failure(
|
|
kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A name that never resolves is retried until the deadline, then reported."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
with (
|
|
patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve,
|
|
patch("time.sleep"),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2)
|
|
is not ProbeResult.ACCEPTED
|
|
)
|
|
assert resolve.call_count > 1
|
|
assert any("failed, unreachable" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_probe_ota_key_resolves_again_after_a_reboot() -> None:
|
|
"""The lookup fails while the device reboots, then the probe goes on."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
device = FakeEncryptedDevice()
|
|
device.start()
|
|
real = espota2.resolve_ip_address
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
side_effect=[EsphomeError("not yet"), real("127.0.0.1", device.port)],
|
|
),
|
|
patch("time.sleep"),
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5)
|
|
is ProbeResult.ACCEPTED
|
|
)
|
|
device.join_and_check()
|
|
|
|
|
|
def test_bare_block_refuses_a_device_that_cannot_encrypt(
|
|
caplog: pytest.LogCaptureFixture, tmp_path: Path
|
|
) -> None:
|
|
"""What the CLI sends for a bare `encryption:` block: a key with neither
|
|
fallback. The retry loop never reconnects in plaintext."""
|
|
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
|
|
with patch("time.sleep"), caplog.at_level(logging.WARNING):
|
|
rc = _run_ota(device, b"firmware", tmp_path, PSK, plaintext_fallback=False)
|
|
device.join_and_check()
|
|
assert rc == 1
|
|
assert device.received != b"firmware"
|
|
assert any("Refusing to send the image" in r.message for r in caplog.records)
|
|
assert not any("Retrying in plaintext" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_probe_ota_key_recomputes_the_budget_after_connect() -> None:
|
|
"""A slow connect leaves less for the handshake than the budget had."""
|
|
clock = [0.0]
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=RESOLVED,
|
|
),
|
|
patch("socket.socket") as sock_cls,
|
|
patch("time.sleep"),
|
|
patch("time.monotonic", side_effect=lambda: clock[0]),
|
|
):
|
|
sock = sock_cls.return_value
|
|
sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 0.4)
|
|
sock.recv.return_value = b""
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED
|
|
)
|
|
connect_timeout, handshake_timeout = (
|
|
c.args[0] for c in sock.settimeout.call_args_list[:2]
|
|
)
|
|
assert connect_timeout == 0.5
|
|
assert handshake_timeout == pytest.approx(0.1)
|
|
|
|
|
|
def test_probe_ota_key_tries_every_address_before_a_final_no() -> None:
|
|
"""With retry_rejected off a rejection is final only once every resolved
|
|
address has answered; a stale cached IP must not veto the real device."""
|
|
session = Mock()
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=TWO_ADDRESSES,
|
|
),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=[espota2.OTAKeyRejected("no"), (session, 2, 0, True)],
|
|
) as negotiate,
|
|
patch("esphome.espota2.receive_exactly"),
|
|
patch("time.sleep") as sleep,
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False)
|
|
is ProbeResult.ACCEPTED
|
|
)
|
|
assert negotiate.call_count == 2
|
|
sleep.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"answer", [espota2.RESPONSE_REQUEST_AUTH, espota2.RESPONSE_REQUEST_SHA256_AUTH]
|
|
)
|
|
def test_probe_ota_key_takes_a_password_challenge_as_proof(answer: int) -> None:
|
|
"""A device with a password as well as encryption answers the handshake
|
|
with a challenge; it arrives encrypted, so the key was accepted."""
|
|
session = Mock()
|
|
session.recv.return_value = bytes([answer])
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=RESOLVED,
|
|
),
|
|
patch("socket.socket"),
|
|
patch("esphome.espota2._negotiate_session", return_value=(session, 2, 0, True)),
|
|
):
|
|
assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED
|
|
|
|
|
|
def test_probe_ota_key_waits_for_every_address_to_answer() -> None:
|
|
"""One address rejecting does not stand in for another that only failed
|
|
to connect so far; the one that answered is not asked again."""
|
|
session = Mock()
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=TWO_ADDRESSES,
|
|
),
|
|
patch("socket.socket") as sock,
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=[
|
|
espota2.OTAKeyRejected("no"),
|
|
OSError("refused"),
|
|
(session, 2, 0, True),
|
|
],
|
|
) as negotiate,
|
|
patch("esphome.espota2.receive_exactly"),
|
|
patch("time.sleep"),
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False)
|
|
is ProbeResult.ACCEPTED
|
|
)
|
|
assert negotiate.call_count == 3
|
|
assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [
|
|
("10.0.0.1", 1),
|
|
("10.0.0.2", 1),
|
|
("10.0.0.2", 1),
|
|
]
|
|
|
|
|
|
def test_probe_ota_key_pauses_between_rounds_once_an_address_answered() -> None:
|
|
"""A stale address before a device that said no keeps the round pacing:
|
|
the device is not asked again and the stale address is not hammered."""
|
|
clock = [0.0]
|
|
|
|
def sleep(seconds: float) -> None:
|
|
clock[0] += seconds
|
|
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=TWO_ADDRESSES,
|
|
) as resolve,
|
|
patch("socket.socket") as sock_cls,
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=itertools.chain(
|
|
[OSError("refused"), espota2.OTAKeyRejected("no")],
|
|
itertools.repeat(OSError("refused")),
|
|
),
|
|
) as negotiate,
|
|
patch("time.sleep", side_effect=sleep) as sleeper,
|
|
patch("time.monotonic", side_effect=lambda: clock[0]),
|
|
):
|
|
assert ProbeResult.ACCEPTED is not espota2.probe_ota_key(
|
|
"h", 1, PSK, timeout=30, retry_rejected=False
|
|
)
|
|
# Ten rounds of one stale attempt each, a pause between every two
|
|
assert negotiate.call_count == 11
|
|
assert sleeper.call_count == 10
|
|
assert [c.args[0] for c in sock_cls.return_value.connect.call_args_list].count(
|
|
("10.0.0.2", 1)
|
|
) == 1
|
|
# Every later round had no answer, so the host was resolved again each time
|
|
assert resolve.call_count == 10
|
|
|
|
|
|
def test_probe_ota_key_keeps_trying_an_address_a_new_lookup_added() -> None:
|
|
"""An address that answered earlier and is gone from the lookup does not
|
|
count against the ones still pending."""
|
|
clock = [0.0]
|
|
|
|
def sleep(seconds: float) -> None:
|
|
clock[0] += seconds
|
|
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
side_effect=itertools.chain(
|
|
[TWO_ADDRESSES],
|
|
itertools.repeat([(2, 1, 0, "", ("10.0.0.3", 1))]),
|
|
),
|
|
),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=itertools.chain(
|
|
[espota2.OTAKeyRejected("no")], itertools.repeat(OSError("down"))
|
|
),
|
|
) as negotiate,
|
|
patch("time.sleep", side_effect=sleep),
|
|
patch("time.monotonic", side_effect=lambda: clock[0]),
|
|
):
|
|
assert ProbeResult.ACCEPTED is not espota2.probe_ota_key(
|
|
"h", 1, PSK, timeout=30, retry_rejected=False
|
|
)
|
|
assert negotiate.call_count == 11 # the new address was tried every round
|
|
|
|
|
|
def test_probe_ota_key_keeps_the_device_answer_when_a_later_lookup_fails(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
clock = [0.0]
|
|
|
|
def sleep(seconds: float) -> None:
|
|
clock[0] += seconds
|
|
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
side_effect=itertools.chain(
|
|
[TWO_ADDRESSES], itertools.repeat(EsphomeError("dns down"))
|
|
),
|
|
),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=[espota2.OTAKeyRejected("no"), OSError("down")],
|
|
),
|
|
patch("time.sleep", side_effect=sleep),
|
|
patch("time.monotonic", side_effect=lambda: clock[0]),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False)
|
|
is ProbeResult.REJECTED
|
|
)
|
|
assert "failed, rejected: 10.0.0.1: no" in caplog.text
|
|
|
|
|
|
def test_probe_ota_key_names_a_missing_encryption_offer(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The upload's advice about the image does not apply to a probe."""
|
|
with (
|
|
patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=espota2.OTAEncryptionNotOffered("did not offer encryption"),
|
|
),
|
|
patch("time.sleep"),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False)
|
|
is ProbeResult.NOT_OFFERED
|
|
)
|
|
assert "failed, not offered: 127.0.0.1: did not offer encryption" in caplog.text
|
|
assert "image" not in caplog.text
|
|
|
|
|
|
def test_probe_ota_key_resolves_again_after_a_round_without_an_answer() -> None:
|
|
"""A round with no answer looks the host up again, and the device is
|
|
found under its new address."""
|
|
session = Mock()
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
side_effect=[
|
|
[(2, 1, 0, "", ("10.0.0.1", 1))],
|
|
[(2, 1, 0, "", ("10.0.0.2", 1))],
|
|
],
|
|
) as resolve,
|
|
patch("socket.socket") as sock,
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=[OSError("unreachable"), (session, 2, 0, True)],
|
|
),
|
|
patch("esphome.espota2.receive_exactly"),
|
|
patch("time.sleep"),
|
|
):
|
|
assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED
|
|
assert resolve.call_count == 2
|
|
assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [
|
|
("10.0.0.1", 1),
|
|
("10.0.0.2", 1),
|
|
]
|
|
|
|
|
|
def test_probe_ota_key_reports_the_device_answer_over_a_network_error(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A stale address failing last must not hide that the device said no."""
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=TWO_ADDRESSES,
|
|
),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=itertools.chain(
|
|
[espota2.OTAKeyRejected("wrong key")],
|
|
itertools.repeat(OSError("refused")),
|
|
),
|
|
),
|
|
patch("time.sleep"),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=0.05, retry_rejected=False)
|
|
is ProbeResult.REJECTED
|
|
)
|
|
assert "failed, rejected: 10.0.0.1: wrong key" in caplog.text
|
|
|
|
|
|
def test_probe_ota_key_reports_a_local_fault_as_its_own() -> None:
|
|
"""A malformed key never reads as the device refusing it."""
|
|
pytest.importorskip("aioesphomeapi.noise")
|
|
with (
|
|
patch("esphome.espota2.resolve_ip_address") as resolve,
|
|
pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"),
|
|
):
|
|
espota2.probe_ota_key("h", 1, "not-base64!!!", timeout=30)
|
|
resolve.assert_not_called()
|
|
|
|
|
|
def test_probe_ota_key_takes_another_device_answer_as_final() -> None:
|
|
"""An unsupported protocol version does not change with a retry."""
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=RESOLVED,
|
|
),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=espota2.OTAError("unsupported OTA version 9"),
|
|
) as negotiate,
|
|
patch("time.sleep") as sleep,
|
|
):
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False)
|
|
is ProbeResult.DEVICE_ERROR
|
|
)
|
|
negotiate.assert_called_once()
|
|
sleep.assert_not_called()
|
|
|
|
|
|
def test_probe_ota_key_retries_a_device_error_while_it_reboots() -> None:
|
|
"""After an upload the device may answer oddly until it is back."""
|
|
session = Mock()
|
|
with (
|
|
patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED),
|
|
patch("socket.socket"),
|
|
patch(
|
|
"esphome.espota2._negotiate_session",
|
|
side_effect=[espota2.OTAError("busy"), (session, 2, 0, True)],
|
|
) as negotiate,
|
|
patch("esphome.espota2.receive_exactly"),
|
|
patch("time.sleep"),
|
|
):
|
|
assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED
|
|
assert negotiate.call_count == 2
|
|
|
|
|
|
def test_probe_ota_key_deadline_spans_the_whole_negotiation() -> None:
|
|
"""Each read re-arms the timeout from what is left, so a slow peer
|
|
cannot stretch the probe one full timeout per read."""
|
|
clock = [0.0]
|
|
sock = Mock()
|
|
|
|
def slow_recv(_amount: int) -> bytes:
|
|
clock[0] += 0.3
|
|
return b"\x00\x02"
|
|
|
|
sock.recv.side_effect = slow_recv
|
|
with patch("time.monotonic", side_effect=lambda: clock[0]):
|
|
wrapped = espota2._DeadlineSocket(sock, 0.5)
|
|
wrapped.sendall(b"x")
|
|
assert wrapped.recv(2) == b"\x00\x02"
|
|
assert wrapped.recv(2) == b"\x00\x02"
|
|
with pytest.raises(TimeoutError):
|
|
wrapped.recv(1)
|
|
timeouts = [c.args[0] for c in sock.settimeout.call_args_list]
|
|
assert timeouts == [0.5, 0.5, pytest.approx(0.2)]
|
|
|
|
|
|
def test_probe_ota_key_stops_when_connect_used_the_budget() -> None:
|
|
"""No handshake timeout is granted past the deadline."""
|
|
clock = [0.0]
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=RESOLVED,
|
|
),
|
|
patch("socket.socket") as sock_cls,
|
|
patch("time.sleep") as sleep,
|
|
patch("time.monotonic", side_effect=lambda: clock[0]),
|
|
):
|
|
sock = sock_cls.return_value
|
|
sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 1.0)
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED
|
|
)
|
|
assert [c.args[0] for c in sock.settimeout.call_args_list] == [0.5]
|
|
sock.sendall.assert_not_called()
|
|
sleep.assert_not_called()
|
|
|
|
|
|
def test_probe_ota_key_never_waits_past_its_deadline() -> None:
|
|
"""Connect and handshake timeouts and the retry sleep are all capped by
|
|
what is left of the budget."""
|
|
with (
|
|
patch(
|
|
"esphome.espota2.resolve_ip_address",
|
|
return_value=RESOLVED,
|
|
),
|
|
patch("socket.socket") as sock_cls,
|
|
patch("time.sleep") as sleep,
|
|
):
|
|
sock = sock_cls.return_value
|
|
sock.connect.side_effect = OSError("refused")
|
|
assert (
|
|
espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED
|
|
)
|
|
timeouts = [c.args[0] for c in sock.settimeout.call_args_list]
|
|
assert all(t <= 0.5 for t in timeouts)
|
|
assert all(c.args[0] <= 0.5 for c in sleep.call_args_list)
|