From 85ef3f6fe36a1542d349b863fcea7f7482441633 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 10 Feb 2026 16:54:04 -0500 Subject: [PATCH 1/3] feat: strict platform name validation This implements strict platform name validation using the validator in craft-platforms (see: https://github.com/canonical/craft-platforms/pull/209) By default, projects only get validated if their base doesn't match any of our "legacy" bases (ubuntu 20.04-24.04). --- craft_application/models/platforms.py | 54 +++++++++++++++++++++- craft_application/models/project.py | 3 +- craft_application/services/project.py | 39 ++++++++++++++++ docs/reference/changelog.rst | 11 +++++ docs/reference/models/platforms.rst | 9 ++++ pyproject.toml | 3 +- tests/unit/models/test_platforms.py | 35 +++++++++++++++ tests/unit/services/test_project.py | 64 ++++++++++++++++++++++++++- uv.lock | 10 ++--- 9 files changed, 217 insertions(+), 11 deletions(-) diff --git a/craft_application/models/platforms.py b/craft_application/models/platforms.py index 8013d358b..e87172e59 100644 --- a/craft_application/models/platforms.py +++ b/craft_application/models/platforms.py @@ -22,6 +22,7 @@ import craft_platforms import pydantic +from craft_platforms import platform_types from pydantic_core import core_schema as cs from typing_extensions import Any, Self, TypeVar @@ -60,6 +61,7 @@ def _validate_platform_name(name: str) -> str: ), ] PlatformNameAdapter = pydantic.TypeAdapter[str](PlatformName) +StrictPlatformNameAdapter = pydantic.TypeAdapter[str](platform_types.StrictPlatformName) class Platform(base.CraftBaseModel): @@ -153,10 +155,11 @@ def from_platforms(cls, platforms: craft_platforms.Platforms) -> dict[str, Self] return result +NameType = TypeVar("NameType", bound=str) PT = TypeVar("PT", bound=Platform) -class GenericPlatformsDict(dict[PlatformName, PT]): +class _GenericPlatformsDict(dict[NameType, PT]): """A generic dictionary describing the contents of the platforms key. This class exists to generate Pydantic and JSON schemas for the platforms key on @@ -251,6 +254,46 @@ def __get_pydantic_json_schema__( return json_schema +class GenericPlatformsDict(_GenericPlatformsDict[PlatformName, PT]): + """A generic dictionary describing the contents of the platforms key. + + This class exists to generate Pydantic and JSON schemas for the platforms key on + a project. By making it a generic, an application can override the Platform + definition and provide its own PlatformsDict. A side effect of this, however, is + that an application cannot simply use the generic directly. Instead, it must create + a non-generic child class and use that. + """ + + +class GenericStrictPlatformsDict( + _GenericPlatformsDict[platform_types.StrictPlatformName, PT] +): + """A generic platforms dictionary but with strict platform names.""" + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: type, handler: pydantic.GetCoreSchemaHandler + ) -> cs.CoreSchema: + """Get the Pydantic CoreSchema for this PlatformsDict. + + From a Pydantic perspective, this dict is merely a ``dict[str, PT]``, where + ``PT`` is the type of the Platform field. It is unlikely to need to override + this method. + """ + try: + (value_type,) = get_args( + cls.__orig_bases__[0] # type: ignore[attr-defined] + ) + except (ValueError, AttributeError): + raise RuntimeError( + "Cannot get value type. This likely means the application is using " + "GenericPlatformsDict directly rather than creating a child class." + ) + return cs.dict_schema( + StrictPlatformNameAdapter.core_schema, value_type.__pydantic_core_schema__ + ) + + class PlatformsDict(GenericPlatformsDict[Platform]): """A dictionary with a Pydantic schema for the general platforms key. @@ -260,3 +303,12 @@ class PlatformsDict(GenericPlatformsDict[Platform]): :ref:`platform-schema` may use this directly. Applications that need their own ``Platform`` model can override :py:class:`.GenericPlatformsDict`. """ + + +class StrictPlatformsDict(GenericStrictPlatformsDict[Platform]): + """A dictionary with a Pydantic schema for a platforms key with strict names. + + This is a Pydantic model for the ``platforms`` dictionary on a ``Project`` + model. If a project model can use strict platform names, this dictionary should be + used to represent the platforms. + """ diff --git a/craft_application/models/project.py b/craft_application/models/project.py index e9fad377a..c64a3447a 100644 --- a/craft_application/models/project.py +++ b/craft_application/models/project.py @@ -41,6 +41,7 @@ from craft_application.models.platforms import ( Platform, PlatformsDict, + StrictPlatformsDict, ) @@ -134,7 +135,7 @@ class Project(base.CraftBaseModel): base: str | None = None build_base: str | None = None - platforms: PlatformsDict = pydantic.Field( + platforms: PlatformsDict | StrictPlatformsDict = pydantic.Field( description="Determines which architectures the project builds and runs on.", examples=[ "{amd64: {build-on: [amd64], build-for: [amd64]}, arm64: {build-on: [amd64, arm64], build-for: [arm64]}}" diff --git a/craft_application/services/project.py b/craft_application/services/project.py index d603b3273..2268d44be 100644 --- a/craft_application/services/project.py +++ b/craft_application/services/project.py @@ -27,6 +27,7 @@ import distro_support import pydantic from craft_cli import emit +from craft_platforms import validators from distro_support.errors import ( UnknownDistributionError, UnknownVersionError, @@ -45,6 +46,20 @@ from .service_factory import ServiceFactory +NON_STRICT_PLATFORM_NAME_BASES = ( + "ubuntu:20.04", + "ubuntu@20.04", + "core20", + "ubuntu:22.04", + "ubuntu@22.04", + "core22", + "ubuntu:24.04", + "ubuntu@24.04", + "core24", +) +"""Bases that do not get strict platform name validation.""" + + class ProjectService(base.AppService): """A service for handling access to the project.""" @@ -283,6 +298,11 @@ def get_platforms(self) -> dict[str, craft_platforms.PlatformDict]: file_name=self.project_file_name, ) from None self._validate_multi_base(self.__platforms) + if self.strict_platform_names: + for name in self.__platforms: + validators.validate_strict_platform_name( + name, allow_app_characters=False + ) return copy.deepcopy(self.__platforms) def _validate_multi_base( @@ -790,3 +810,22 @@ def _deep_update(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any] else: base[key] = new_value return base + + @property + def strict_platform_names(self) -> bool: + """Determine whether to use strict platform names. + + Applications may override this. The default behaviour is to return True for any + base or build base in a list of legacy bases or False otherwise. + Having a base or build-base of ``devel`` always returns True. + """ + base = self._load_raw_project().get("base") + build_base = self._load_raw_project().get("build-base") + + if "devel" in (base, build_base): + return True + + if not build_base and base in NON_STRICT_PLATFORM_NAME_BASES: + return False + + return build_base not in NON_STRICT_PLATFORM_NAME_BASES diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index 27c4ea193..266b2adbc 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -15,6 +15,16 @@ Changelog For a complete list of commits, check out the `1.2.3`_ release on GitHub. +6.2.0 (unreleased) +------------------ + +- The project service now has a + :py:class:`~craft_application.services.project.ProjectService.strict_platform_names` + property that, when True, enforces strict platform name validation. The property is + by default True for all bases except Ubuntu 20.04, 22.04, and 24.04. + +For a complete list of commits, check out the `6.2.0`_ release on GitHub. + 6.1.1 (2026-01-27) ------------------ @@ -1191,3 +1201,4 @@ For a complete list of commits, check out the `2.7.0`_ release on GitHub. .. _6.0.1: https://github.com/canonical/craft-application/releases/tag/6.0.1 .. _6.1.0: https://github.com/canonical/craft-application/releases/tag/6.1.0 .. _6.1.1: https://github.com/canonical/craft-application/releases/tag/6.1.1 +.. _6.2.0: https://github.com/canonical/craft-application/releases/tag/6.2.0 diff --git a/docs/reference/models/platforms.rst b/docs/reference/models/platforms.rst index d62ec238c..4daf7395f 100644 --- a/docs/reference/models/platforms.rst +++ b/docs/reference/models/platforms.rst @@ -45,6 +45,9 @@ unreserved name. The name can't contain forward slashes (/). .. autoclass:: craft_application.models.PlatformsDict :show-inheritance: +.. autoclass:: craft_application.models.StrictPlatformsDict + :show-inheritance: + Inheritance ----------- @@ -62,3 +65,9 @@ class has several validators that may need to be modified. :undoc-members: :private-members: _shorthand_keys, __get_pydantic_core_schema__ :special-members: __get_pydantic_core_schema__, __get_pydantic_json_schema__ + +.. autoclass:: craft_application.models.GenericStrictPlatformsDict + :members: + :undoc-members: + :private-members: _shorthand_keys, __get_pydantic_core_schema__ + :special-members: __get_pydantic_core_schema__, __get_pydantic_json_schema__ diff --git a/pyproject.toml b/pyproject.toml index 102cd1d9a..6995c112e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ dependencies = [ "craft-cli>=3.2.0", "craft-grammar>=2.3.0", "craft-parts>=2.28.0", - "craft-platforms>=0.6.0", + # "craft-platforms>=0.6.0", + "craft-platforms@git+https://github.com/canonical/craft-platforms@work/CRAFT-4879", "craft-providers>=3.3.0", "Jinja2>=3.1.6,<4.0.0", "snap-helpers>=0.4.2", diff --git a/tests/unit/models/test_platforms.py b/tests/unit/models/test_platforms.py index 5846aa8fd..d7f4c8ffe 100644 --- a/tests/unit/models/test_platforms.py +++ b/tests/unit/models/test_platforms.py @@ -22,6 +22,7 @@ from craft_application.models.platforms import ( RESERVED_PLATFORM_NAMES, PlatformsDict, + StrictPlatformsDict, ) from hypothesis import given, strategies @@ -56,3 +57,37 @@ def test_platform_name_invalid_character(name): def test_fuzz_platform_name(name): adapter = pydantic.TypeAdapter(PlatformsDict) adapter.validate_python({name: {"build-on": ["riscv64"], "build-for": ["s390x"]}}) + + +@pytest.mark.parametrize("name", ["app_only_platform"]) +def test_strict_platform_name_invalid_character(name): + adapter = pydantic.TypeAdapter(StrictPlatformsDict) + with pytest.raises(ValueError, match=f"Invalid platform name: '{name}'"): + adapter.validate_python( + {name: {"build-on": ["riscv64"], "build-for": ["s390x"]}} + ) + + +@given( + strategies.text( + strategies.characters(categories=["L", "N", "So"]), + min_size=1, + ), +) +def test_fuzz_strict_platform_name(name): + adapter = pydantic.TypeAdapter(StrictPlatformsDict) + adapter.validate_python({name: {"build-on": ["riscv64"], "build-for": ["s390x"]}}) + + +@given( + strategies.text( + strategies.characters(exclude_categories=["L", "N", "So"]), + min_size=1, + ), +) +def test_fuzz_strict_platform_name_error(name): + adapter = pydantic.TypeAdapter(StrictPlatformsDict) + with pytest.raises(pydantic.ValidationError): + adapter.validate_python( + {name: {"build-on": ["riscv64"], "build-for": ["s390x"]}} + ) diff --git a/tests/unit/services/test_project.py b/tests/unit/services/test_project.py index f52776159..cb84db1e2 100644 --- a/tests/unit/services/test_project.py +++ b/tests/unit/services/test_project.py @@ -28,7 +28,10 @@ import pytest_mock from craft_application import errors, models from craft_application.application import AppMetadata -from craft_application.services.project import ProjectService +from craft_application.services.project import ( + NON_STRICT_PLATFORM_NAME_BASES, + ProjectService, +) from craft_application.services.service_factory import ServiceFactory from craft_parts import ProjectVar, ProjectVarInfo from hypothesis import given, strategies @@ -305,6 +308,65 @@ def test_get_platforms_bad_value( real_project_service.get_platforms() +@pytest.mark.parametrize( + "platforms", + [ + { + "__THIS_IS_NOT_A_VALID_STRICT_PLATFORM_NAME__": { + "build-on": ["riscv64"], + "build-for": ["riscv64"], + }, + }, + { + ";": { + "build-on": ["riscv64"], + "build-for": ["riscv64"], + }, + }, + ], +) +@pytest.mark.parametrize("base", NON_STRICT_PLATFORM_NAME_BASES) +@pytest.mark.parametrize("base_key", ["base", "build-base"]) +def test_get_platforms_strict_name_exemption( + real_project_service: ProjectService, platforms, base, base_key +): + real_project_service._load_raw_project = lambda: { # type: ignore[invalid-assignment] + "platforms": platforms, + base_key: base, + } + real_project_service.get_platforms() + + +@pytest.mark.parametrize( + "platforms", + [ + { + "__THIS_IS_NOT_A_VALID_STRICT_PLATFORM_NAME__": { + "build-on": ["riscv64"], + "build-for": ["riscv64"], + }, + }, + { + ";": { + "build-on": ["riscv64"], + "build-for": ["riscv64"], + }, + }, + ], +) +@pytest.mark.parametrize("base", ["devel", "ubuntu@26.04"]) +def test_get_platforms_strict_name_error( + real_project_service: ProjectService, platforms, base +): + real_project_service._load_raw_project = lambda: { # type: ignore[invalid-assignment] + "platforms": platforms, + "base": base, + } + + with pytest.raises(craft_platforms.InvalidPlatformNameError): + real_project_service.get_platforms() + + @pytest.mark.parametrize( ("data", "expected"), [ diff --git a/uv.lock b/uv.lock index 555472e0d..45fcd1bff 100644 --- a/uv.lock +++ b/uv.lock @@ -618,7 +618,7 @@ requires-dist = [ { name = "craft-cli", specifier = ">=3.2.0" }, { name = "craft-grammar", specifier = ">=2.3.0" }, { name = "craft-parts", specifier = ">=2.28.0" }, - { name = "craft-platforms", specifier = ">=0.6.0" }, + { name = "craft-platforms", git = "https://github.com/canonical/craft-platforms?rev=work%2FCRAFT-4879" }, { name = "craft-providers", specifier = ">=3.3.0" }, { name = "distro-support", specifier = ">=2025.12.16" }, { name = "jinja2", specifier = ">=3.1.6,<4.0.0" }, @@ -747,17 +747,13 @@ wheels = [ [[package]] name = "craft-platforms" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } +version = "0.10.0.post44+g4d14dd9" +source = { git = "https://github.com/canonical/craft-platforms?rev=work%2FCRAFT-4879#4d14dd951f6b89b81089458eaea343c652cc8352" } dependencies = [ { name = "annotated-types" }, { name = "distro" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/78/f2c3ef342c9e9fee0127516aee113a28c487a999d35ce4aa944a58bd5939/craft_platforms-0.10.0.tar.gz", hash = "sha256:85b8630c0f7436b0832466c1dba8deb040502fdadc1d225fbed15d1e1e38f729", size = 220454, upload-time = "2025-07-17T19:35:11.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/61/9985a7bd77fdafd14fd5de658877202f867802849de61f1346d7d124dcd5/craft_platforms-0.10.0-py3-none-any.whl", hash = "sha256:5be0a53a9ef6ac6341c44842378c6293b09148d371ac1a4ef26d1014b330907a", size = 30262, upload-time = "2025-07-17T19:35:09.619Z" }, -] [[package]] name = "craft-providers" From a828436f363ec87edb420212e22108e27beed8ac Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 10 Feb 2026 16:56:25 -0500 Subject: [PATCH 2/3] fix: api --- craft_application/models/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/craft_application/models/__init__.py b/craft_application/models/__init__.py index b430ae956..906d482d5 100644 --- a/craft_application/models/__init__.py +++ b/craft_application/models/__init__.py @@ -31,8 +31,10 @@ from craft_application.models.metadata import BaseMetadata from craft_application.models.platforms import ( GenericPlatformsDict, + GenericStrictPlatformsDict, Platform, PlatformsDict, + StrictPlatformsDict, ) from craft_application.models.project import ( DEVEL_BASE_INFOS, @@ -55,9 +57,11 @@ "get_grammar_aware_part_keywords", "GrammarAwareProject", "GenericPlatformsDict", + "GenericStrictPlatformsDict", "PackState", "Platform", "PlatformsDict", + "StrictPlatformsDict", "Project", "ProjectName", "ProjectTitle", From 8adeb3be25604e7a75589bc51aa670e2f87bc779 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 5 Mar 2026 13:57:18 -0500 Subject: [PATCH 3/3] Update pyproject.toml Signed-off-by: Alex Lowe --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6995c112e..45610d631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,7 @@ dependencies = [ "craft-cli>=3.2.0", "craft-grammar>=2.3.0", "craft-parts>=2.28.0", - # "craft-platforms>=0.6.0", - "craft-platforms@git+https://github.com/canonical/craft-platforms@work/CRAFT-4879", + "craft-platforms>=0.11.0", "craft-providers>=3.3.0", "Jinja2>=3.1.6,<4.0.0", "snap-helpers>=0.4.2",