From 5b19c139cbf219e801e950b0f2bb49f474af3494 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 16:09:09 -0500 Subject: [PATCH 1/4] Force-quote shell metacharacters outside the safe token set --- esphome/build_helpers/ninja.py | 5 ++++- tests/unit_tests/build_helpers/test_ninja.py | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py index 9a9189e8ee..6a7bac1f56 100644 --- a/esphome/build_helpers/ninja.py +++ b/esphome/build_helpers/ninja.py @@ -52,7 +52,10 @@ def quote_arg(tok: str) -> str: return f'"{quoted}"' -_NEEDS_QUOTE = re.compile(r'[\s"\']') +# Force-quote any token containing a character outside the shlex.quote-style +# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, ` +# and friends would be re-parsed as shell syntax. +_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]") def shell_token(tok: str, force: bool = False) -> str: diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py index 9143988acd..1dc49396c6 100644 --- a/tests/unit_tests/build_helpers/test_ninja.py +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -62,11 +62,17 @@ def test_quote_arg_windows_argv_rule() -> None: def test_shell_token_quotes_only_when_needed() -> None: assert ninja_helper.shell_token("-Os") == "-Os" - assert ninja_helper.shell_token("-DX=$HOME") == "-DX=$$HOME" assert ninja_helper.shell_token("-DP=C:\\x y") == '"-DP=C:\\x y"' assert ninja_helper.shell_token("plain", force=True) == '"plain"' +def test_shell_token_quotes_shell_metacharacters() -> None: + """Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare.""" + assert ninja_helper.shell_token("-DMASK=(1<<3)") == '"-DMASK=(1<<3)"' + assert ninja_helper.shell_token("-DX=a;b") == '"-DX=a;b"' + assert ninja_helper.shell_token("-DX=$HOME") == '"-DX=$$HOME"' + + def test_quote_path_force_quotes() -> None: assert ninja_helper.quote_path(Path("a b")) == '"a b"' assert ninja_helper.quote_path("simple") == '"simple"' From 80815f1dc71bd9345a86260a85595a746ba9246b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 16:12:52 -0500 Subject: [PATCH 2/4] Make parse_library_json public alongside its properties sibling --- esphome/platformio/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 771570f872..ccc4c3d36c 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -459,7 +459,7 @@ def check_library_data(data: dict, platform: str | None, framework: str): ) -def _parse_library_json(library_json_path: PathType): +def parse_library_json(library_json_path: PathType): """ Load and parse a JSON file describing a library. @@ -876,7 +876,7 @@ def convert_libraries( has_json = library_json_path.is_file() has_properties = library_properties_path.is_file() if has_json: - component.data = _parse_library_json(library_json_path) + component.data = parse_library_json(library_json_path) elif has_properties: component.data = parse_library_properties(library_properties_path) else: From 7fe0847e5c91e73691e3c29a8709c742d73f3f8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 16:13:19 -0500 Subject: [PATCH 3/4] Update espidf test to the public parse_library_json name --- tests/unit_tests/test_espidf_component.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index c4a910be23..b17136aa15 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -25,10 +25,10 @@ from esphome.platformio.library import ( GitSource, URLSource, _node_key, - _parse_library_json, _resolve_registry_version, collect_filtered_files, normalize_dependencies, + parse_library_json, parse_library_properties, split_list_by_condition, ) @@ -368,11 +368,11 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component): generate_idf_component_yml(tmp_component) -def test_parse_library_json(tmp_path): +def testparse_library_json(tmp_path): f = tmp_path / "library.json" f.write_text(json.dumps({"name": "test"})) - result = _parse_library_json(f) + result = parse_library_json(f) assert result["name"] == "test" From 10b13558c3ab7d68f85b69b0ee7a5481dfe585dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 16:13:55 -0500 Subject: [PATCH 4/4] Line-oriented rspfile expansion, adopt the public manifest parser, document the dependency policy --- esphome/arduino/library.py | 7 +++++-- esphome/build_gen/build_tool.py | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 75ce906b77..443438a725 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -28,7 +28,6 @@ from esphome.platformio.library import ( ConvertedLibrary, InvalidLibrary, LibraryBackend, - _parse_library_json, check_library_data, collect_filtered_files, convert_libraries, @@ -37,6 +36,7 @@ from esphome.platformio.library import ( lex_build_flags, lib_ignore_set, normalize_dependencies, + parse_library_json, parse_library_properties, ) @@ -138,7 +138,7 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary: lib_dir = framework_path / "libraries" / name manifest_json = lib_dir / "library.json" if manifest_json.is_file(): - data = _parse_library_json(manifest_json) + data = parse_library_json(manifest_json) else: manifest = lib_dir / "library.properties" data = parse_library_properties(manifest) if manifest.is_file() else {} @@ -174,6 +174,9 @@ def resolve_libraries( and "/" not in library.name and (framework_path / "libraries" / library.name).is_dir() ): + # A bundled library's own manifest dependencies are deliberately + # not walked (PlatformIO's lib_ldf_mode=off does not either); + # core add_library() calls list what they need explicitly. bundled.append(_bundled_library(framework_path, library.name)) else: external.append(library) diff --git a/esphome/build_gen/build_tool.py b/esphome/build_gen/build_tool.py index ef6b1d4cc2..692d006661 100644 --- a/esphome/build_gen/build_tool.py +++ b/esphome/build_gen/build_tool.py @@ -24,9 +24,13 @@ def main() -> int: # Expand the response file here instead of passing @rspfile: GNU ar # treats backslashes in response files as escapes, corrupting Windows # paths ("sub\a.o" -> "suba.o"). - objects = Path(rspfile).read_text(encoding="utf-8").split() + # One path per line (rspfile_content = $in_newline, written without + # escaping), so a path containing a space survives. Expanding into + # argv trades away the OS command-line length limit rspfiles dodge; + # the relative object paths used here stay far below it. + objects = Path(rspfile).read_text(encoding="utf-8").splitlines() return subprocess.run( - [ar, "rc", archive, *objects], check=False, close_fds=False + [ar, "rc", archive, *filter(None, objects)], check=False, close_fds=False ).returncode if mode == "copy": src, dst = sys.argv[2:4]