From fa27e2581ca25933a515fcb1c202f5fbf3ab250d Mon Sep 17 00:00:00 2001 From: Moomal Alvi Date: Thu, 3 Sep 2026 17:00:20 +0500 Subject: [PATCH] fix(packages): validate recipe list fields --- ebuild/packages/recipe.py | 59 +++++++++++++-- tests/ebuild/test_package_recipe.py | 107 ++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 tests/ebuild/test_package_recipe.py diff --git a/ebuild/packages/recipe.py b/ebuild/packages/recipe.py index 71bf2c2..26388ae 100644 --- a/ebuild/packages/recipe.py +++ b/ebuild/packages/recipe.py @@ -82,6 +82,7 @@ def validate(self) -> None: f"Package '{self.name}': plaintext http:// is not accepted for " f"'{self.url}'. Use https://." ) + if self.build_system not in self.VALID_BUILD_SYSTEMS: raise RecipeError( f"Package '{self.name}': invalid build system '{self.build_system}'. " @@ -89,7 +90,45 @@ def validate(self) -> None: ) -def _parse_recipe(raw: Dict[str, Any], source_path: Optional[Path] = None) -> PackageRecipe: +def _parse_string_list( + raw: Dict[str, Any], + field_name: str, + fallback_field: Optional[str] = None, +) -> List[str]: + """Parse a recipe field that must be a list of strings. + + Args: + raw: Raw recipe mapping loaded from YAML. + field_name: Preferred field name. + fallback_field: Optional legacy/alternate field name used when the + preferred field is absent. + + Returns: + A copy of the validated list. + + Raises: + RecipeError: If the field is not a list or contains non-string items. + """ + if field_name in raw: + value = raw[field_name] + elif fallback_field is not None and fallback_field in raw: + value = raw[fallback_field] + else: + value = [] + + if not isinstance(value, list): + raise RecipeError(f"'{field_name}' must be a list.") + + if not all(isinstance(item, str) for item in value): + raise RecipeError(f"'{field_name}' must contain only strings.") + + return list(value) + + +def _parse_recipe( + raw: Dict[str, Any], + source_path: Optional[Path] = None, +) -> PackageRecipe: """Parse a raw YAML dict into a PackageRecipe.""" recipe = PackageRecipe( name=raw.get("package", raw.get("name", "")), @@ -97,14 +136,19 @@ def _parse_recipe(raw: Dict[str, Any], source_path: Optional[Path] = None) -> Pa url=raw.get("url", ""), checksum=raw.get("checksum", ""), build_system=raw.get("build", raw.get("build_system", "cmake")), - dependencies=raw.get("dependencies", raw.get("depends", [])), - patches=raw.get("patches", []), - configure_args=raw.get("configure_args", []), - build_args=raw.get("build_args", []), - install_args=raw.get("install_args", []), + dependencies=_parse_string_list( + raw, + "dependencies", + fallback_field="depends", + ), + patches=_parse_string_list(raw, "patches"), + configure_args=_parse_string_list(raw, "configure_args"), + build_args=_parse_string_list(raw, "build_args"), + install_args=_parse_string_list(raw, "install_args"), description=raw.get("description", ""), license=raw.get("license", ""), ) + recipe.validate() return recipe @@ -123,6 +167,7 @@ def load_recipe(recipe_path: str | Path) -> PackageRecipe: FileNotFoundError: If the file doesn't exist. """ recipe_path = Path(recipe_path) + if not recipe_path.exists(): raise FileNotFoundError(f"Recipe file not found: {recipe_path}") @@ -138,6 +183,8 @@ def load_recipe(recipe_path: str | Path) -> PackageRecipe: def load_recipe_from_string(content: str) -> PackageRecipe: """Load a package recipe from a YAML string.""" raw = yaml.safe_load(content) + if not isinstance(raw, dict): raise RecipeError("Invalid recipe format: expected a YAML mapping.") + return _parse_recipe(raw) diff --git a/tests/ebuild/test_package_recipe.py b/tests/ebuild/test_package_recipe.py new file mode 100644 index 0000000..36675c3 --- /dev/null +++ b/tests/ebuild/test_package_recipe.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Tests for ebuild.packages.recipe.""" + +import pytest + +from ebuild.packages.recipe import RecipeError, load_recipe_from_string + + +BASE_RECIPE = """ +package: demo +version: 1.0.0 +url: https://example.com/demo.tar.gz +checksum: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +""" + + +@pytest.mark.parametrize( + "field_name", + [ + "dependencies", + "patches", + "configure_args", + "build_args", + "install_args", + ], +) +def test_recipe_list_fields_must_be_lists(field_name): + """List-valued recipe fields must reject scalar YAML values.""" + content = BASE_RECIPE + f""" +{field_name}: "not-a-list" +""" + + with pytest.raises(RecipeError, match=field_name): + load_recipe_from_string(content) + + +@pytest.mark.parametrize( + "field_name", + [ + "dependencies", + "patches", + "configure_args", + "build_args", + "install_args", + ], +) +def test_recipe_list_fields_must_contain_only_strings(field_name): + """List-valued recipe fields must reject non-string items.""" + content = BASE_RECIPE + f""" +{field_name}: + - valid-value + - 123 +""" + + with pytest.raises(RecipeError, match=field_name): + load_recipe_from_string(content) + + +def test_recipe_accepts_valid_list_fields(): + """Valid lists of strings should continue to load normally.""" + recipe = load_recipe_from_string( + BASE_RECIPE + + """ +dependencies: + - zlib +patches: + - fix-build.patch +configure_args: + - -DENABLE_FEATURE=ON +build_args: + - VERBOSE=1 +install_args: + - DESTDIR=/tmp/install +""" + ) + + assert recipe.dependencies == ["zlib"] + assert recipe.patches == ["fix-build.patch"] + assert recipe.configure_args == ["-DENABLE_FEATURE=ON"] + assert recipe.build_args == ["VERBOSE=1"] + assert recipe.install_args == ["DESTDIR=/tmp/install"] + + +def test_depends_alias_accepts_a_list(): + """The legacy 'depends' alias should remain supported.""" + recipe = load_recipe_from_string( + BASE_RECIPE + + """ +depends: + - zlib + - openssl +""" + ) + + assert recipe.dependencies == ["zlib", "openssl"] + + +def test_depends_alias_must_be_a_list(): + """The legacy 'depends' alias must follow the same list validation.""" + content = BASE_RECIPE + """ +depends: zlib +""" + + with pytest.raises(RecipeError, match="dependencies"): + load_recipe_from_string(content) \ No newline at end of file