mirror of
https://github.com/esphome/esphome.git
synced 2026-02-08 08:41:59 +00:00
1155 lines
37 KiB
Python
1155 lines
37 KiB
Python
"""Tests for esphome.bundle module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import tarfile
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from esphome.bundle import (
|
|
BUNDLE_EXTENSION,
|
|
CURRENT_MANIFEST_VERSION,
|
|
MANIFEST_FILENAME,
|
|
BundleManifest,
|
|
ConfigBundleCreator,
|
|
ManifestKey,
|
|
_add_bytes_to_tar,
|
|
_default_target_dir,
|
|
_find_used_secret_keys,
|
|
extract_bundle,
|
|
is_bundle_path,
|
|
prepare_bundle_for_compile,
|
|
read_bundle_manifest,
|
|
)
|
|
from esphome.core import CORE, EsphomeError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_bundle(
|
|
tmp_path: Path,
|
|
config_filename: str = "test.yaml",
|
|
config_content: str = "esphome:\n name: test\n",
|
|
manifest_overrides: dict[str, Any] | None = None,
|
|
extra_files: dict[str, bytes] | None = None,
|
|
*,
|
|
include_manifest: bool = True,
|
|
raw_members: list[tarfile.TarInfo] | None = None,
|
|
) -> Path:
|
|
"""Create a minimal bundle tar.gz for testing."""
|
|
bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}"
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
if include_manifest:
|
|
manifest: dict[str, Any] = {
|
|
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
|
ManifestKey.ESPHOME_VERSION: "2026.2.0-test",
|
|
ManifestKey.CONFIG_FILENAME: config_filename,
|
|
ManifestKey.FILES: [config_filename],
|
|
ManifestKey.HAS_SECRETS: False,
|
|
}
|
|
if manifest_overrides:
|
|
manifest.update(manifest_overrides)
|
|
_add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode())
|
|
|
|
_add_bytes_to_tar(tar, config_filename, config_content.encode())
|
|
|
|
if extra_files:
|
|
for name, data in extra_files.items():
|
|
_add_bytes_to_tar(tar, name, data)
|
|
|
|
if raw_members:
|
|
for info in raw_members:
|
|
tar.addfile(info, io.BytesIO(b""))
|
|
|
|
bundle_path.write_bytes(buf.getvalue())
|
|
return bundle_path
|
|
|
|
|
|
def _setup_config_dir(
|
|
tmp_path: Path,
|
|
files: dict[str, str] | None = None,
|
|
) -> Path:
|
|
"""Set up a fake config directory with files and configure CORE."""
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir()
|
|
|
|
config_yaml = "esphome:\n name: test\n"
|
|
(config_dir / "test.yaml").write_text(config_yaml)
|
|
|
|
if files:
|
|
for rel_path, content in files.items():
|
|
p = config_dir / rel_path
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(content)
|
|
|
|
CORE.config_path = config_dir / "test.yaml"
|
|
return config_dir
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_bundle_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("filename", "expected"),
|
|
[
|
|
(f"my_device{BUNDLE_EXTENSION}", True),
|
|
(f"MY_DEVICE{BUNDLE_EXTENSION.upper()}", True),
|
|
("my_device.yaml", False),
|
|
("my_device.tar.gz", False),
|
|
("my_device.zip", False),
|
|
("", False),
|
|
],
|
|
)
|
|
def test_is_bundle_path(filename: str, expected: bool) -> None:
|
|
assert is_bundle_path(Path(filename)) is expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _default_target_dir
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_default_target_dir_strips_extension() -> None:
|
|
p = Path(f"/builds/device{BUNDLE_EXTENSION}")
|
|
result = _default_target_dir(p)
|
|
assert result == Path("/builds/device")
|
|
|
|
|
|
def test_default_target_dir_no_extension() -> None:
|
|
p = Path("/builds/device.other")
|
|
result = _default_target_dir(p)
|
|
assert result == Path("/builds/device.other")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_used_secret_keys
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_find_used_secret_keys(tmp_path: Path) -> None:
|
|
yaml1 = tmp_path / "a.yaml"
|
|
yaml1.write_text("wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_pw\n")
|
|
yaml2 = tmp_path / "b.yaml"
|
|
yaml2.write_text("api:\n key: !secret api_key\n")
|
|
|
|
keys = _find_used_secret_keys([yaml1, yaml2])
|
|
assert keys == {"wifi_ssid", "wifi_pw", "api_key"}
|
|
|
|
|
|
def test_find_used_secret_keys_no_secrets(tmp_path: Path) -> None:
|
|
yaml1 = tmp_path / "a.yaml"
|
|
yaml1.write_text("esphome:\n name: test\n")
|
|
|
|
keys = _find_used_secret_keys([yaml1])
|
|
assert keys == set()
|
|
|
|
|
|
def test_find_used_secret_keys_missing_file(tmp_path: Path) -> None:
|
|
missing = tmp_path / "does_not_exist.yaml"
|
|
keys = _find_used_secret_keys([missing])
|
|
assert keys == set()
|
|
|
|
|
|
def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None:
|
|
yaml1 = tmp_path / "a.yaml"
|
|
yaml1.write_text("a: !secret key1\nb: !secret key1\n")
|
|
|
|
keys = _find_used_secret_keys([yaml1])
|
|
assert keys == {"key1"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _add_bytes_to_tar
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_add_bytes_to_tar_deterministic_metadata() -> None:
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
_add_bytes_to_tar(tar, "hello.txt", b"world")
|
|
|
|
buf.seek(0)
|
|
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
|
|
member = tar.getmember("hello.txt")
|
|
assert member.size == 5
|
|
assert member.mtime == 0
|
|
assert member.uid == 0
|
|
assert member.gid == 0
|
|
assert member.mode == 0o644
|
|
assert tar.extractfile(member).read() == b"world"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ManifestKey
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_manifest_key_values() -> None:
|
|
assert ManifestKey.MANIFEST_VERSION == "manifest_version"
|
|
assert ManifestKey.ESPHOME_VERSION == "esphome_version"
|
|
assert ManifestKey.CONFIG_FILENAME == "config_filename"
|
|
assert ManifestKey.FILES == "files"
|
|
assert ManifestKey.HAS_SECRETS == "has_secrets"
|
|
|
|
|
|
def test_manifest_key_is_str() -> None:
|
|
"""Verify ManifestKey values work as dict keys and JSON keys."""
|
|
d: dict[str, int] = {ManifestKey.MANIFEST_VERSION: 1}
|
|
assert d["manifest_version"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_bundle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extract_bundle_basic(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(tmp_path)
|
|
target = tmp_path / "output"
|
|
|
|
config_path = extract_bundle(bundle_path, target)
|
|
|
|
assert config_path.is_file()
|
|
assert config_path.name == "test.yaml"
|
|
assert config_path.read_text().startswith("esphome:")
|
|
assert (target / MANIFEST_FILENAME).is_file()
|
|
|
|
|
|
def test_extract_bundle_default_target_dir(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(tmp_path)
|
|
|
|
config_path = extract_bundle(bundle_path)
|
|
|
|
expected_dir = tmp_path / "device"
|
|
assert config_path.parent == expected_dir
|
|
|
|
|
|
def test_extract_bundle_missing_file(tmp_path: Path) -> None:
|
|
missing = tmp_path / f"missing{BUNDLE_EXTENSION}"
|
|
with pytest.raises(EsphomeError, match="Bundle file not found"):
|
|
extract_bundle(missing)
|
|
|
|
|
|
def test_extract_bundle_missing_manifest(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(tmp_path, include_manifest=False)
|
|
with pytest.raises(EsphomeError, match="missing manifest.json"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_future_manifest_version(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(
|
|
tmp_path,
|
|
manifest_overrides={ManifestKey.MANIFEST_VERSION: 999},
|
|
)
|
|
with pytest.raises(EsphomeError, match="newer than this ESPHome"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_missing_config_filename_in_manifest(tmp_path: Path) -> None:
|
|
"""Manifest exists but is missing config_filename key."""
|
|
bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}"
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
manifest = {ManifestKey.MANIFEST_VERSION: 1}
|
|
_add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode())
|
|
_add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n")
|
|
bundle_path.write_bytes(buf.getvalue())
|
|
|
|
with pytest.raises(EsphomeError, match="missing 'config_filename'"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_config_not_in_archive(tmp_path: Path) -> None:
|
|
"""Manifest references a config file that isn't in the archive."""
|
|
bundle_path = _make_bundle(
|
|
tmp_path,
|
|
config_filename="test.yaml",
|
|
manifest_overrides={ManifestKey.CONFIG_FILENAME: "missing.yaml"},
|
|
)
|
|
with pytest.raises(EsphomeError, match="was not found in the archive"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_with_extra_files(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(
|
|
tmp_path,
|
|
extra_files={
|
|
"common/base.yaml": b"level: DEBUG\n",
|
|
"includes/sensor.h": b"#pragma once\n",
|
|
},
|
|
)
|
|
target = tmp_path / "out"
|
|
extract_bundle(bundle_path, target)
|
|
|
|
assert (target / "common" / "base.yaml").read_text() == "level: DEBUG\n"
|
|
assert (target / "includes" / "sensor.h").read_text() == "#pragma once\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_bundle - security validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extract_bundle_rejects_absolute_path(tmp_path: Path) -> None:
|
|
info = tarfile.TarInfo(name="/etc/passwd")
|
|
info.size = 0
|
|
bundle_path = _make_bundle(tmp_path, raw_members=[info])
|
|
|
|
with pytest.raises(EsphomeError, match="absolute path"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_rejects_path_traversal(tmp_path: Path) -> None:
|
|
info = tarfile.TarInfo(name="../../../etc/passwd")
|
|
info.size = 0
|
|
bundle_path = _make_bundle(tmp_path, raw_members=[info])
|
|
|
|
with pytest.raises(EsphomeError, match="path traversal"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_rejects_backslash_path_traversal(tmp_path: Path) -> None:
|
|
info = tarfile.TarInfo(name="foo\\..\\..\\etc\\passwd")
|
|
info.size = 0
|
|
bundle_path = _make_bundle(tmp_path, raw_members=[info])
|
|
|
|
with pytest.raises(EsphomeError, match="path traversal"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_rejects_symlink(tmp_path: Path) -> None:
|
|
info = tarfile.TarInfo(name="evil_link")
|
|
info.type = tarfile.SYMTYPE
|
|
info.linkname = "/etc/passwd"
|
|
info.size = 0
|
|
bundle_path = _make_bundle(tmp_path, raw_members=[info])
|
|
|
|
with pytest.raises(EsphomeError, match="symlink"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_rejects_oversized(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Archive whose total decompressed size exceeds the limit is rejected."""
|
|
# Lower the limit so we don't need huge test data
|
|
monkeypatch.setattr("esphome.bundle.MAX_DECOMPRESSED_SIZE", 100)
|
|
|
|
bundle_path = _make_bundle(
|
|
tmp_path,
|
|
extra_files={"big.bin": b"\x00" * 200},
|
|
)
|
|
|
|
with pytest.raises(EsphomeError, match="decompressed size exceeds"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_corrupted_tar(tmp_path: Path) -> None:
|
|
"""Corrupted tar file raises EsphomeError."""
|
|
bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}"
|
|
bundle_path.write_bytes(b"not a tar file at all")
|
|
|
|
with pytest.raises(EsphomeError, match="Failed to extract bundle"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_malformed_manifest_json(tmp_path: Path) -> None:
|
|
"""Invalid JSON in manifest.json raises EsphomeError."""
|
|
bundle_path = tmp_path / f"badjson{BUNDLE_EXTENSION}"
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
_add_bytes_to_tar(tar, MANIFEST_FILENAME, b"{invalid json")
|
|
_add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n")
|
|
bundle_path.write_bytes(buf.getvalue())
|
|
|
|
with pytest.raises(EsphomeError, match="malformed manifest.json"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_missing_manifest_version(tmp_path: Path) -> None:
|
|
"""Manifest without manifest_version raises EsphomeError."""
|
|
bundle_path = tmp_path / f"nover{BUNDLE_EXTENSION}"
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
manifest = {ManifestKey.CONFIG_FILENAME: "test.yaml"}
|
|
_add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode())
|
|
_add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n")
|
|
bundle_path.write_bytes(buf.getvalue())
|
|
|
|
with pytest.raises(EsphomeError, match="missing 'manifest_version'"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_invalid_manifest_version_type(tmp_path: Path) -> None:
|
|
"""Non-integer manifest_version raises EsphomeError."""
|
|
bundle_path = _make_bundle(
|
|
tmp_path,
|
|
manifest_overrides={ManifestKey.MANIFEST_VERSION: "not_an_int"},
|
|
)
|
|
|
|
with pytest.raises(EsphomeError, match="must be a positive integer"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_manifest_version_zero(tmp_path: Path) -> None:
|
|
"""manifest_version of 0 is rejected."""
|
|
bundle_path = _make_bundle(
|
|
tmp_path,
|
|
manifest_overrides={ManifestKey.MANIFEST_VERSION: 0},
|
|
)
|
|
|
|
with pytest.raises(EsphomeError, match="must be a positive integer"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
def test_extract_bundle_manifest_not_regular_file(tmp_path: Path) -> None:
|
|
"""manifest.json that is a directory entry raises EsphomeError."""
|
|
bundle_path = tmp_path / f"dirmanifest{BUNDLE_EXTENSION}"
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
# Add manifest.json as a directory instead of a file
|
|
dir_info = tarfile.TarInfo(name=MANIFEST_FILENAME)
|
|
dir_info.type = tarfile.DIRTYPE
|
|
dir_info.size = 0
|
|
tar.addfile(dir_info)
|
|
_add_bytes_to_tar(tar, "test.yaml", b"esphome:\n name: test\n")
|
|
bundle_path.write_bytes(buf.getvalue())
|
|
|
|
with pytest.raises(EsphomeError, match="not a regular file"):
|
|
extract_bundle(bundle_path, tmp_path / "out")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# read_bundle_manifest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None:
|
|
"""Corrupted tar file raises EsphomeError via read_bundle_manifest."""
|
|
bundle_path = tmp_path / f"bad{BUNDLE_EXTENSION}"
|
|
bundle_path.write_bytes(b"not a tar file")
|
|
|
|
with pytest.raises(EsphomeError, match="Failed to read bundle"):
|
|
read_bundle_manifest(bundle_path)
|
|
|
|
|
|
def test_read_bundle_manifest(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(
|
|
tmp_path,
|
|
manifest_overrides={ManifestKey.HAS_SECRETS: True},
|
|
extra_files={"secrets.yaml": b"wifi: test\n"},
|
|
)
|
|
|
|
manifest = read_bundle_manifest(bundle_path)
|
|
|
|
assert isinstance(manifest, BundleManifest)
|
|
assert manifest.manifest_version == CURRENT_MANIFEST_VERSION
|
|
assert manifest.esphome_version == "2026.2.0-test"
|
|
assert manifest.config_filename == "test.yaml"
|
|
assert manifest.has_secrets is True
|
|
|
|
|
|
def test_read_bundle_manifest_minimal(tmp_path: Path) -> None:
|
|
"""Manifest with only required fields."""
|
|
bundle_path = tmp_path / f"min{BUNDLE_EXTENSION}"
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
manifest = {
|
|
ManifestKey.MANIFEST_VERSION: 1,
|
|
ManifestKey.CONFIG_FILENAME: "cfg.yaml",
|
|
}
|
|
_add_bytes_to_tar(tar, MANIFEST_FILENAME, json.dumps(manifest).encode())
|
|
_add_bytes_to_tar(tar, "cfg.yaml", b"")
|
|
bundle_path.write_bytes(buf.getvalue())
|
|
|
|
result = read_bundle_manifest(bundle_path)
|
|
assert result.esphome_version == "unknown"
|
|
assert result.files == []
|
|
assert result.has_secrets is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# prepare_bundle_for_compile
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_prepare_bundle_preserves_build_cache(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(tmp_path)
|
|
target = tmp_path / "work"
|
|
target.mkdir()
|
|
|
|
# Pre-existing build cache
|
|
esphome_dir = target / ".esphome"
|
|
esphome_dir.mkdir()
|
|
(esphome_dir / "build_state.json").write_text('{"cached": true}')
|
|
|
|
pio_dir = target / ".pioenvs"
|
|
pio_dir.mkdir()
|
|
(pio_dir / "firmware.bin").write_bytes(b"\x00" * 100)
|
|
|
|
config_path = prepare_bundle_for_compile(bundle_path, target)
|
|
|
|
assert config_path.is_file()
|
|
# Build caches should be preserved
|
|
assert (target / ".esphome" / "build_state.json").read_text() == '{"cached": true}'
|
|
assert (target / ".pioenvs" / "firmware.bin").read_bytes() == b"\x00" * 100
|
|
|
|
|
|
def test_prepare_bundle_cleans_old_config(tmp_path: Path) -> None:
|
|
bundle_path = _make_bundle(tmp_path)
|
|
target = tmp_path / "work"
|
|
target.mkdir()
|
|
|
|
# Old config from previous extraction
|
|
(target / "old_config.yaml").write_text("old: true")
|
|
old_dir = target / "old_includes"
|
|
old_dir.mkdir()
|
|
(old_dir / "old.h").write_text("// old")
|
|
|
|
prepare_bundle_for_compile(bundle_path, target)
|
|
|
|
# Old files should be cleaned
|
|
assert not (target / "old_config.yaml").exists()
|
|
assert not (target / "old_includes").exists()
|
|
# New config should exist
|
|
assert (target / "test.yaml").is_file()
|
|
|
|
|
|
def test_prepare_bundle_missing_file(tmp_path: Path) -> None:
|
|
missing = tmp_path / f"missing{BUNDLE_EXTENSION}"
|
|
with pytest.raises(EsphomeError, match="Bundle file not found"):
|
|
prepare_bundle_for_compile(missing)
|
|
|
|
|
|
def test_prepare_bundle_default_target_dir(tmp_path: Path) -> None:
|
|
"""prepare_bundle_for_compile uses default dir when target_dir is None."""
|
|
bundle_path = _make_bundle(tmp_path)
|
|
|
|
config_path = prepare_bundle_for_compile(bundle_path)
|
|
|
|
expected_dir = tmp_path / "device"
|
|
assert config_path.parent == expected_dir
|
|
assert config_path.is_file()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ConfigBundleCreator - file discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_discover_files_includes_config(tmp_path: Path) -> None:
|
|
_setup_config_dir(tmp_path)
|
|
|
|
creator = ConfigBundleCreator({})
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "test.yaml" in paths
|
|
|
|
|
|
def test_discover_files_finds_path_objects(tmp_path: Path) -> None:
|
|
"""Path objects in validated config are discovered."""
|
|
config_dir = _setup_config_dir(
|
|
tmp_path,
|
|
files={"assets/font.ttf": "fake font data"},
|
|
)
|
|
|
|
config: dict[str, Any] = {"font": [{"file": config_dir / "assets" / "font.ttf"}]}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "assets/font.ttf" in paths
|
|
|
|
|
|
def test_discover_files_finds_absolute_string_paths(tmp_path: Path) -> None:
|
|
"""Absolute string paths in validated config are discovered."""
|
|
config_dir = _setup_config_dir(
|
|
tmp_path,
|
|
files={"assets/logo.png": "fake png data"},
|
|
)
|
|
|
|
abs_path = str(config_dir / "assets" / "logo.png")
|
|
config: dict[str, Any] = {"image": [{"file": abs_path}]}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "assets/logo.png" in paths
|
|
|
|
|
|
def test_discover_files_skips_non_path_prefixes(tmp_path: Path) -> None:
|
|
"""Remote URLs and special prefixes are not treated as file paths."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"font": [
|
|
{"file": "https://example.com/font.ttf"},
|
|
{"file": "mdi:home"},
|
|
{"file": "http://example.com/icon.png"},
|
|
]
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
# Only the config file itself
|
|
assert len(files) == 1
|
|
assert files[0].path == "test.yaml"
|
|
|
|
|
|
def test_discover_files_skips_multiline_strings(tmp_path: Path) -> None:
|
|
"""Lambda/template strings are not treated as file paths."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"sensor": [{"lambda": "auto val = id(sensor1);\nreturn val;"}]
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
assert len(files) == 1
|
|
|
|
|
|
def test_discover_files_deduplicates(tmp_path: Path) -> None:
|
|
"""Same file referenced twice is only included once."""
|
|
config_dir = _setup_config_dir(
|
|
tmp_path,
|
|
files={"cert.pem": "fake cert"},
|
|
)
|
|
|
|
abs_path = str(config_dir / "cert.pem")
|
|
config: dict[str, Any] = {
|
|
"a": {"cert": abs_path},
|
|
"b": {"cert": abs_path},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
cert_files = [f for f in files if f.path == "cert.pem"]
|
|
assert len(cert_files) == 1
|
|
|
|
|
|
def test_discover_files_skips_outside_config_dir(tmp_path: Path) -> None:
|
|
"""Files outside the config directory are skipped."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
outside_file = tmp_path / "outside.pem"
|
|
outside_file.write_text("outside cert")
|
|
|
|
config: dict[str, Any] = {"cert": str(outside_file)}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "outside.pem" not in paths
|
|
|
|
|
|
def test_discover_files_esphome_includes(tmp_path: Path) -> None:
|
|
"""Paths listed in esphome.includes are discovered."""
|
|
_setup_config_dir(
|
|
tmp_path,
|
|
files={"my_sensor.h": "#pragma once\n"},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"esphome": {"includes": ["my_sensor.h"]},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "my_sensor.h" in paths
|
|
|
|
|
|
def test_discover_files_esphome_includes_directory(tmp_path: Path) -> None:
|
|
"""esphome.includes pointing to a directory adds all files."""
|
|
_setup_config_dir(
|
|
tmp_path,
|
|
files={
|
|
"my_lib/a.h": "// a",
|
|
"my_lib/b.cpp": "// b",
|
|
},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"esphome": {"includes": ["my_lib"]},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "my_lib/a.h" in paths
|
|
assert "my_lib/b.cpp" in paths
|
|
|
|
|
|
def test_discover_files_esphome_includes_skips_system(tmp_path: Path) -> None:
|
|
"""System includes like <Arduino.h> are not added."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"esphome": {"includes": ["<Arduino.h>"]},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert len(paths) == 1 # Just test.yaml
|
|
|
|
|
|
def test_discover_files_external_components_local(tmp_path: Path) -> None:
|
|
"""external_components with type: local adds the directory."""
|
|
_setup_config_dir(
|
|
tmp_path,
|
|
files={
|
|
"components/my_comp/__init__.py": "# comp",
|
|
"components/my_comp/sensor.py": "# sensor",
|
|
},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"external_components": [{"source": {"type": "local", "path": "components"}}],
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "components/my_comp/__init__.py" in paths
|
|
assert "components/my_comp/sensor.py" in paths
|
|
|
|
|
|
def test_discover_files_external_components_non_dict_source(tmp_path: Path) -> None:
|
|
"""external_components with string source (e.g. github shorthand) is skipped."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"external_components": [{"source": "github://user/repo@main"}],
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
# Only the config file itself - no crash from non-dict source
|
|
assert len(files) == 1
|
|
assert files[0].path == "test.yaml"
|
|
|
|
|
|
def test_discover_files_nested_config_values(tmp_path: Path) -> None:
|
|
"""Deeply nested Path objects in lists/dicts are found."""
|
|
config_dir = _setup_config_dir(
|
|
tmp_path,
|
|
files={"deep/file.pem": "cert data"},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"level1": {"level2": [{"level3": config_dir / "deep" / "file.pem"}]}
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "deep/file.pem" in paths
|
|
|
|
|
|
def test_discover_files_idempotent_secrets(tmp_path: Path) -> None:
|
|
"""Calling discover_files twice does not accumulate secrets paths."""
|
|
config_dir = _setup_config_dir(tmp_path)
|
|
(config_dir / "secrets.yaml").write_text("k: v\n")
|
|
(config_dir / "test.yaml").write_text("a: !secret k\n")
|
|
|
|
creator = ConfigBundleCreator({})
|
|
files1 = creator.discover_files()
|
|
files2 = creator.discover_files()
|
|
|
|
# Both calls should return the same result (secrets not accumulated)
|
|
paths1 = [f.path for f in files1]
|
|
paths2 = [f.path for f in files2]
|
|
assert "secrets.yaml" in paths1
|
|
assert paths1 == paths2
|
|
|
|
|
|
def test_discover_files_skips_missing_file(tmp_path: Path) -> None:
|
|
"""_add_file logs warning for non-existent files via includes."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
# Include references a file that doesn't exist on disk
|
|
config: dict[str, Any] = {
|
|
"esphome": {"includes": ["nonexistent.h"]},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "nonexistent.h" not in paths
|
|
|
|
|
|
def test_discover_files_skips_missing_directory(tmp_path: Path) -> None:
|
|
"""_add_directory logs warning for non-existent directories."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"external_components": [
|
|
{"source": {"type": "local", "path": "nonexistent_dir"}}
|
|
],
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
# Only the config file
|
|
assert len(files) == 1
|
|
|
|
|
|
def test_discover_files_yaml_reload_failure(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""YAML reload failure during include discovery is handled gracefully."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
def _raise_error(*args, **kwargs):
|
|
raise EsphomeError("parse error")
|
|
|
|
monkeypatch.setattr("esphome.yaml_util.load_yaml", _raise_error)
|
|
|
|
creator = ConfigBundleCreator({})
|
|
files = creator.discover_files()
|
|
|
|
# Should still have the config file at minimum
|
|
paths = [f.path for f in files]
|
|
assert "test.yaml" in paths
|
|
|
|
|
|
def test_discover_files_esphome_includes_c(tmp_path: Path) -> None:
|
|
"""Paths listed in esphome.includes_c are discovered."""
|
|
_setup_config_dir(
|
|
tmp_path,
|
|
files={"my_code.c": "// c code"},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"esphome": {"includes_c": ["my_code.c"]},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "my_code.c" in paths
|
|
|
|
|
|
def test_discover_files_external_components_non_local_type(tmp_path: Path) -> None:
|
|
"""external_components with type != 'local' are skipped."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"external_components": [
|
|
{"source": {"type": "git", "url": "https://github.com/user/repo"}}
|
|
],
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
assert len(files) == 1
|
|
|
|
|
|
def test_discover_files_external_components_no_path(tmp_path: Path) -> None:
|
|
"""external_components with local type but missing path are skipped."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"external_components": [{"source": {"type": "local"}}],
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
assert len(files) == 1
|
|
|
|
|
|
def test_discover_files_external_components_absolute_path(tmp_path: Path) -> None:
|
|
"""external_components with absolute path are resolved correctly."""
|
|
config_dir = _setup_config_dir(
|
|
tmp_path,
|
|
files={"ext/comp/__init__.py": "# comp"},
|
|
)
|
|
|
|
abs_path = str(config_dir / "ext")
|
|
config: dict[str, Any] = {
|
|
"external_components": [{"source": {"type": "local", "path": abs_path}}],
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "ext/comp/__init__.py" in paths
|
|
|
|
|
|
def test_discover_files_relative_string_with_known_extension(tmp_path: Path) -> None:
|
|
"""Relative strings with known extensions are resolved and warned."""
|
|
_setup_config_dir(
|
|
tmp_path,
|
|
files={"my_cert.pem": "cert data"},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"component": {"cert": "my_cert.pem"},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "my_cert.pem" in paths
|
|
|
|
|
|
def test_discover_files_relative_string_missing_file(tmp_path: Path) -> None:
|
|
"""Relative strings with known extensions that don't exist are skipped."""
|
|
_setup_config_dir(tmp_path)
|
|
|
|
config: dict[str, Any] = {
|
|
"component": {"cert": "nonexistent.pem"},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
assert len(files) == 1
|
|
|
|
|
|
def test_discover_files_esphome_includes_absolute_path(tmp_path: Path) -> None:
|
|
"""esphome.includes with absolute path is handled."""
|
|
config_dir = _setup_config_dir(
|
|
tmp_path,
|
|
files={"my_code.h": "#pragma once"},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"esphome": {"includes": [str(config_dir / "my_code.h")]},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "my_code.h" in paths
|
|
|
|
|
|
def test_discover_files_walk_tuple_values(tmp_path: Path) -> None:
|
|
"""Tuples in config are walked like lists."""
|
|
config_dir = _setup_config_dir(
|
|
tmp_path,
|
|
files={"a.pem": "cert"},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"items": (config_dir / "a.pem",),
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
files = creator.discover_files()
|
|
|
|
paths = [f.path for f in files]
|
|
assert "a.pem" in paths
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ConfigBundleCreator - create_bundle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None:
|
|
_setup_config_dir(tmp_path)
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
assert isinstance(result.data, bytes)
|
|
assert len(result.data) > 0
|
|
|
|
# Verify it's a valid tar.gz
|
|
buf = io.BytesIO(result.data)
|
|
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
|
|
names = tar.getnames()
|
|
assert MANIFEST_FILENAME in names
|
|
assert "test.yaml" in names
|
|
|
|
|
|
def test_create_bundle_manifest_content(tmp_path: Path) -> None:
|
|
_setup_config_dir(tmp_path)
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
manifest = result.manifest
|
|
assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION
|
|
assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml"
|
|
assert "test.yaml" in manifest[ManifestKey.FILES]
|
|
|
|
|
|
def test_create_bundle_filters_secrets(tmp_path: Path) -> None:
|
|
config_dir = _setup_config_dir(tmp_path)
|
|
|
|
# Create secrets.yaml with multiple secrets
|
|
secrets = config_dir / "secrets.yaml"
|
|
secrets.write_text(
|
|
"wifi_ssid: MyNetwork\nwifi_pw: secret123\nunused: should_not_appear\n"
|
|
)
|
|
|
|
# Config that references only some secrets
|
|
config_yaml = "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_pw\n"
|
|
(config_dir / "test.yaml").write_text(config_yaml)
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
# Extract and check secrets
|
|
buf = io.BytesIO(result.data)
|
|
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
|
|
secrets_data = tar.extractfile("secrets.yaml").read().decode()
|
|
|
|
assert "wifi_ssid" in secrets_data
|
|
assert "wifi_pw" in secrets_data
|
|
assert "unused" not in secrets_data
|
|
assert "should_not_appear" not in secrets_data
|
|
|
|
|
|
def test_create_bundle_no_secrets(tmp_path: Path) -> None:
|
|
_setup_config_dir(tmp_path)
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
|
|
|
|
|
def test_create_bundle_secrets_load_failure(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Secrets file that fails to load during filtering is skipped gracefully."""
|
|
config_dir = _setup_config_dir(tmp_path)
|
|
(config_dir / "secrets.yaml").write_text("k: v\n")
|
|
(config_dir / "test.yaml").write_text("a: !secret k\n")
|
|
|
|
from esphome import yaml_util as yu
|
|
|
|
original_load = yu.load_yaml
|
|
|
|
def _failing_on_filter(fname, *args, clear_secrets=True, **kwargs):
|
|
# Fail only when _build_filtered_secrets calls with clear_secrets=False
|
|
if not clear_secrets and "secrets" in str(fname):
|
|
raise EsphomeError("corrupt secrets")
|
|
return original_load(fname, *args, clear_secrets=clear_secrets, **kwargs)
|
|
|
|
monkeypatch.setattr(yu, "load_yaml", _failing_on_filter)
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
# Should succeed without secrets since the filtered load failed
|
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
|
|
|
|
|
def test_create_bundle_secrets_non_dict(tmp_path: Path) -> None:
|
|
"""Secrets file that parses to non-dict is skipped."""
|
|
config_dir = _setup_config_dir(tmp_path)
|
|
(config_dir / "secrets.yaml").write_text("- item1\n- item2\n")
|
|
(config_dir / "test.yaml").write_text("a: !secret k\n")
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
|
|
|
|
|
def test_create_bundle_secrets_no_matching_keys(tmp_path: Path) -> None:
|
|
"""Secrets with no matching keys produces empty filtered result."""
|
|
config_dir = _setup_config_dir(tmp_path)
|
|
(config_dir / "secrets.yaml").write_text("other_key: value\n")
|
|
(config_dir / "test.yaml").write_text("a: !secret nonexistent\n")
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
assert result.manifest[ManifestKey.HAS_SECRETS] is False
|
|
|
|
|
|
def test_create_bundle_deterministic_order(tmp_path: Path) -> None:
|
|
"""Files are added in sorted order for reproducibility."""
|
|
_setup_config_dir(
|
|
tmp_path,
|
|
files={
|
|
"z_last.h": "// z",
|
|
"a_first.h": "// a",
|
|
"m_middle.h": "// m",
|
|
},
|
|
)
|
|
|
|
config: dict[str, Any] = {
|
|
"esphome": {"includes": ["z_last.h", "a_first.h", "m_middle.h"]},
|
|
}
|
|
creator = ConfigBundleCreator(config)
|
|
result = creator.create_bundle()
|
|
|
|
buf = io.BytesIO(result.data)
|
|
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
|
|
names = tar.getnames()
|
|
|
|
# manifest.json is always first, then files in sorted order
|
|
assert names[0] == MANIFEST_FILENAME
|
|
file_names = [n for n in names if n != MANIFEST_FILENAME]
|
|
assert file_names == sorted(file_names)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Round-trip: create then extract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bundle_round_trip(tmp_path: Path) -> None:
|
|
"""A bundle created by ConfigBundleCreator can be extracted."""
|
|
_setup_config_dir(
|
|
tmp_path,
|
|
files={"include.h": "#pragma once\n"},
|
|
)
|
|
config: dict[str, Any] = {"esphome": {"includes": ["include.h"]}}
|
|
|
|
creator = ConfigBundleCreator(config)
|
|
result = creator.create_bundle()
|
|
|
|
bundle_path = tmp_path / f"roundtrip{BUNDLE_EXTENSION}"
|
|
bundle_path.write_bytes(result.data)
|
|
|
|
target = tmp_path / "extracted"
|
|
config_path = extract_bundle(bundle_path, target)
|
|
|
|
assert config_path.is_file()
|
|
assert (target / "include.h").read_text() == "#pragma once\n"
|
|
|
|
manifest = read_bundle_manifest(bundle_path)
|
|
assert manifest.config_filename == "test.yaml"
|
|
assert "include.h" in manifest.files
|
|
|
|
|
|
def test_bundle_round_trip_with_secrets(tmp_path: Path) -> None:
|
|
"""Secrets survive round-trip with correct filtering."""
|
|
config_dir = _setup_config_dir(tmp_path)
|
|
(config_dir / "secrets.yaml").write_text("key1: val1\nkey2: val2\nunused: nope\n")
|
|
(config_dir / "test.yaml").write_text("a: !secret key1\nb: !secret key2\n")
|
|
|
|
creator = ConfigBundleCreator({})
|
|
result = creator.create_bundle()
|
|
|
|
bundle_path = tmp_path / f"secrets{BUNDLE_EXTENSION}"
|
|
bundle_path.write_bytes(result.data)
|
|
|
|
target = tmp_path / "extracted"
|
|
extract_bundle(bundle_path, target)
|
|
|
|
secrets_content = (target / "secrets.yaml").read_text()
|
|
assert "key1" in secrets_content
|
|
assert "key2" in secrets_content
|
|
assert "unused" not in secrets_content
|
|
|
|
manifest = read_bundle_manifest(bundle_path)
|
|
assert manifest.has_secrets is True
|