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/3] 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/3] 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/3] 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"