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", 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 193a66915..a84f8d183 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -15,18 +15,23 @@ Changelog For a complete list of commits, check out the `1.2.3`_ release on GitHub. +6.3.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. 6.2.0 (2026-02-12) ------------------ Services -======== - The Provider service now injects the application's base snap from the host into the build environment. Bug Fixes -========= - Files for the test command, ``spread.yaml`` and ``spread/``, no longer cause the part's build directory to be marked as dirty by Git. diff --git a/docs/reference/models/platforms.rst b/docs/reference/models/platforms.rst index 2b9a74207..2c8840299 100644 --- a/docs/reference/models/platforms.rst +++ b/docs/reference/models/platforms.rst @@ -50,6 +50,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 ----------- @@ -67,3 +70,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 92039ad6d..26012c2f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +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>=0.11.0", "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 0ce5ba4cc..82bee3f69 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"