From 2f9c175f5cac9a752a046ed7e01ea359c21af467 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 25 Oct 2026 09:58:05 -0700 Subject: [PATCH 1/2] Keep Dot Directories Out of Lint Ownership and Update the Lint Container Note --- OPERATIONS.md | 3 ++- tests/compile_lint/test_lint.py | 12 ++++++++++++ tools/compile-lint/src/compile_lint/lint.py | 11 +++++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/OPERATIONS.md b/OPERATIONS.md index 2beb419..60e7f05 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -336,6 +336,7 @@ Do not `!remove` blocks from Apollo's package without checking what depends on t - `espefuse summary` showed `VDD_SPI_FORCE` unburned, so the strap alone decides the flash voltage. - **A power cycle recovers, and the overlay prevents the orderly cases.** Hold the power button for 6 seconds, since a reset leaves the camera streaming. The overlay's `on_shutdown` holds the camera in reset, and an OTA reboot with the camera streaming came back clean. The reset after an esptool flash bypasses that hook. Turn `Camera Enable` off over the API first, and a USB flash then boots without a power cycle. The switch drives the sensor's reset line rather than its rail, so turning it back on resets the sensor's registers and needs a restart. A crash or a brownout bypasses the shutdown hook as well. - **Burning `VDD_SPI` to 3.3V removes the hazard, and nothing here does it.** `espefuse set-flash-voltage 3.3V` is permanent, and the flash voltage is inferred from behavior rather than read from a schematic. The template leaves it to the owner. +- **The camera snapshot endpoint has no authentication.** `esp32_camera_web_server` serves it on port 8081 with no option to protect it, so flash the example device only onto a bench network. - **The camera web server asserts on reboot after the camera failed setup.** `esp32_camera_web_server` marks itself failed before it creates its semaphore, and its `on_shutdown` deletes the null semaphore in `vQueueDelete`. It shows as a crash on the reboot after an OTA, and a working camera never reaches it. - **The microphone and the speaker share one I2S bus, so only one runs at a time.** The example device stops its `sound_level` sensor, waits until the microphone stops capturing, plays the tune, and restarts the sensor when playback finishes. A version without the wait froze the screen on a press, and the version with it has not. - **The touch mapping matches M5GFX, 0 to 319 and 0 to 239 with no offset.** A raw capture over the API ran from y 0 at the top edge to 239 at the bottom. Fingertip taps read up to about 20 pixels low near the bottom and within 4 pixels near the top. That is finger contact rather than a panel offset, so the template does not correct for it. @@ -676,7 +677,7 @@ Sharp edges in the tooling around this repository, each one learned by tripping `--user "$(id -u):$(id -g)"` runs the container as the invoking host user rather than the image's own default, so it reads `$lint_root` through the same owner permission bits `mktemp -d`'s default `0700` already grants, with no `chmod` opening it to any other local account. This assumes a standard rootful Docker daemon, where the container's user namespace is the host's own. Under **rootless** Docker the daemon runs in its own user namespace, so the host UID passed to `--user` maps to an unrelated subordinate UID inside the container rather than to the owner of the bind mount, and the snapshot becomes unreadable instead. On a rootless daemon, `chmod -R o+rX "$lint_root"` after the tar step is the fallback, accepting the wider local-account readability for the run's duration. The trap removes the snapshot on exit either way. - **This includes the hub's `scripts/docker_lint.py` wrapper, and it cannot currently take this snapshot as its `--root`.** Its own target discovery runs `git -C "$root" ls-files`, and the snapshot above deliberately holds only the `git ls-files` output, not `.git` itself, so `--root "$lint_root"` fails with "not a git repository" before any linter runs. Passing the live checkout instead would defeat the whole point of the snapshot. Until the wrapper accepts a plain snapshot or file manifest, lint this repository with the direct `docker run` invocations above rather than `docker_lint.py`. + **The hub's `scripts/docker_lint.py` wrapper builds this same snapshot itself.** It copies the `git ls-files --cached --others --exclude-standard` output into a temporary tree, and each container mounts only that tree, read-only with networking off. So `secrets.yaml` never reaches a container, and running the wrapper with `--root` on a checkout is the route for this repository. - **The installed `gh` is old enough that `gh pr edit` always fails**, on every repository, and the message names a GraphQL field rather than a permission. See [The `gh` CLI Is Too Old for `gh pr edit`][gh-cli-too-old] for the cause and the REST workaround. - **A compile job that fails in about 18 seconds is a registry timeout, not a template defect.** The compile jobs pull the [`ptr727/esphome-nonroot`][esphome-nonroot-link] image, and a slow Docker Hub kills the job with `registry-1.docker.io ... context deadline exceeded` before any ESPHome work starts. A real esp-idf compile runs for minutes, so the duration tells the two apart at a glance. Re-run the job rather than reading the template for a fault that is not there. diff --git a/tests/compile_lint/test_lint.py b/tests/compile_lint/test_lint.py index c4e9e63..bf54f06 100644 --- a/tests/compile_lint/test_lint.py +++ b/tests/compile_lint/test_lint.py @@ -525,3 +525,15 @@ def test_an_inlined_expression_owns_only_the_occurrence_at_the_column(tmp_path: set_main(build, directive(root, 9), " other(q * 2.5); call.set_level(y * 2.5);") result = lint(root, build).run() assert result.findings == [] + + +def test_a_build_tree_under_a_dot_directory_is_not_repository_code(tmp_path: Path) -> None: + root, _ = write_build(tmp_path, "") + generated = root / ".esphome" / "build" / "device-test" / "src" / "esphome" / "core.h" + generated.parent.mkdir(parents=True) + generated.write_text("float f(float v) { return v * 2.5; }\n") + lint_build = BuildLint( + root / ".esphome" / "build" / "device-test", root, [], {}, YamlIndex(root) + ) + assert lint_build.owned_path(generated) is None + assert lint_build.owned_path(root / "test" / "device.yaml") == "test/device.yaml" diff --git a/tools/compile-lint/src/compile_lint/lint.py b/tools/compile-lint/src/compile_lint/lint.py index 5899d8f..2805cdb 100644 --- a/tools/compile-lint/src/compile_lint/lint.py +++ b/tools/compile-lint/src/compile_lint/lint.py @@ -260,11 +260,18 @@ def owned_sources(root: Path) -> dict[str, str]: def relative_to_root(path: Path, root: Path) -> str | None: - """Return a path relative to the config root, or None when it lies outside it.""" + """Return a path relative to the config root, or None outside it or in a dot directory. + + A dot directory such as .esphome holds ESPHome's build tree and fetched packages, not this + repository's code. + """ try: - return path.relative_to(root).as_posix() + relative = path.relative_to(root) except ValueError: return None + if any(part.startswith(".") for part in relative.parts[:-1]): + return None + return relative.as_posix() def emitted_lines(text: str, first: int) -> tuple[tuple[int, str], ...]: From 32b8c2c7ebe2436fc31e7d9c5ec21e8e0b41b8b3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 25 Oct 2026 10:00:35 -0700 Subject: [PATCH 2/2] Keep the Ownership Docstring to One Sentence per Line --- tools/compile-lint/src/compile_lint/lint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/compile-lint/src/compile_lint/lint.py b/tools/compile-lint/src/compile_lint/lint.py index 2805cdb..8f35730 100644 --- a/tools/compile-lint/src/compile_lint/lint.py +++ b/tools/compile-lint/src/compile_lint/lint.py @@ -262,8 +262,7 @@ def owned_sources(root: Path) -> dict[str, str]: def relative_to_root(path: Path, root: Path) -> str | None: """Return a path relative to the config root, or None outside it or in a dot directory. - A dot directory such as .esphome holds ESPHome's build tree and fetched packages, not this - repository's code. + A dot directory such as .esphome holds ESPHome's build tree and packages, not owned code. """ try: relative = path.relative_to(root)