[x] attach debdiff against the package in (old)stable
For real this time. Stefano -- Stefano Rivera http://tumbleweed.org.za/ +1 415 683 3272
diff -Nru debusine-0.11.3/debian/changelog debusine-0.11.3+deb13u1/debian/changelog --- debusine-0.11.3/debian/changelog 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debian/changelog 2026-06-09 08:52:48.000000000 -0400 @@ -1,3 +1,13 @@ +debusine (0.11.3+deb13u1) trixie; urgency=medium + + * Security update for stable: + - Enforce permissions on the file body upload endpoint. + - Restrict artifact relation creation and deletion. + - Sbuild task: harden against shell injection. + - Reject .dsc/.changes checksum filenames with multiple path components. + + -- Stefano Rivera <[email protected]> Tue, 09 Jun 2026 08:52:48 -0400 + debusine (0.11.3) unstable; urgency=medium * client: Allow passing a local copy of the `.changes` file to `debusine diff -Nru debusine-0.11.3/debian/.gitignore debusine-0.11.3+deb13u1/debian/.gitignore --- debusine-0.11.3/debian/.gitignore 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debian/.gitignore 1969-12-31 20:00:00.000000000 -0400 @@ -1 +0,0 @@ -/files diff -Nru debusine-0.11.3/debusine/artifacts/local_artifact.py debusine-0.11.3+deb13u1/debusine/artifacts/local_artifact.py --- debusine-0.11.3/debusine/artifacts/local_artifact.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/artifacts/local_artifact.py 2026-06-09 08:52:48.000000000 -0400 @@ -38,7 +38,11 @@ FilesRequestType, model_to_json_serializable_dict, ) -from debusine.utils import extract_generic_type_arguments +from debusine.utils import ( + extract_generic_type_arguments, + is_single_path_component, + read_changes, +) AD = TypeVar("AD", bound=data_models.ArtifactData) @@ -428,11 +432,10 @@ file to be missing locally; uploading this artifact will require them to already exist on the server. """ - with changes_file.open() as changes_obj: - data = data_models.DebianUpload( - type="dpkg", - changes_fields=deb822dict_to_dict(deb822.Changes(changes_obj)), - ) + data = data_models.DebianUpload( + type="dpkg", + changes_fields=deb822dict_to_dict(read_changes(changes_file)), + ) artifact = cls.construct(category=cls._category, data=data) @@ -441,6 +444,8 @@ # Add any files referenced by .changes (excluding the exclude_files) base_directory = changes_file.parent for file in data.changes_fields.get("Checksums-Sha256", []): + # Checked by read_changes. + assert is_single_path_component(file["name"]) path = base_directory / file["name"] if path not in exclude_files: @@ -499,8 +504,6 @@ for file in files: if file.suffix == ".dsc": dsc = utils.read_dsc(file) - if dsc is None: - raise ValueError(f"{file} is not a valid .dsc file") dsc_fields = deb822dict_to_dict(dsc) data = data_models.DebianSourcePackage( diff -Nru debusine-0.11.3/debusine/artifacts/tests/test_local_artifact.py debusine-0.11.3+deb13u1/debusine/artifacts/tests/test_local_artifact.py --- debusine-0.11.3/debusine/artifacts/tests/test_local_artifact.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/artifacts/tests/test_local_artifact.py 2026-06-09 08:52:48.000000000 -0400 @@ -13,6 +13,7 @@ import re from datetime import datetime, timedelta from pathlib import Path +from textwrap import dedent from typing import Any, ClassVar from unittest import mock @@ -528,6 +529,33 @@ self.assertEqual(binary_upload.files, files) + def test_create_multiple_path_components(self) -> None: + directory = self.create_temporary_directory() + changes_file = directory / "hello.changes" + changes_file.write_text( + dedent( + """\ + Format: 1.8 + Source: hello + Architecture: all + Version: 1.0 + Files: + 0000 1 devel optional ../hello_all.deb + Checksums-Sha1: + 0000 1 ../hello_all.deb + Checksums-Sha256: + 0000 1 ../hello_all.deb + """ + ) + ) + + with self.assertRaisesRegex( + ValueError, + r"'hello\.changes' has an entry in Files with more than one path" + r" component: '\.\./hello_all\.deb'", + ): + Upload.create(changes_file=changes_file) + def test_create_allow_remote(self) -> None: """Upload.create() can allow referenced files to be missing locally.""" directory = self.create_temporary_directory() @@ -908,7 +936,7 @@ """Raise ValueError: invalid .dsc file.""" file = self.create_temporary_file(suffix=".dsc") - expected_message = fr"{file.name} is not a valid \.dsc file" + expected_message = rf"{file.name!r} does not contain Source field" with self.assertRaisesRegex(ValueError, expected_message): SourcePackage.create(name="hello", version="1.0", files=[file]) diff -Nru debusine-0.11.3/debusine/client/client_utils.py debusine-0.11.3+deb13u1/debusine/client/client_utils.py --- debusine-0.11.3/debusine/client/client_utils.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/client/client_utils.py 2026-06-09 08:52:48.000000000 -0400 @@ -34,6 +34,7 @@ Upload, ) from debusine.client import exceptions +from debusine.utils import read_changes, read_dsc SOURCE_PACKAGE_HASHES = ("sha256", "sha1", "md5") log = logging.getLogger(__name__) @@ -296,8 +297,7 @@ def prepare_changes_for_upload(changes: Path) -> list[LocalArtifact[Any]]: """Prepare artifacts to upload based on a ``.changes`` file.""" - with changes.open("rb") as f: - parsed = Changes(f) + parsed = read_changes(changes) architectures = parsed["Architecture"].split() artifact_factories: list[Callable[[], LocalArtifact[Any]]] = [] for architecture in architectures: @@ -367,12 +367,11 @@ """Prepare an artifact to upload based on a ``.dsc`` file.""" files = [dsc] directory = dsc.parent - with dsc.open() as f: - parsed = Dsc(f) - name = parsed["source"] - version = parsed["version"] - for file in parsed["files"]: - files.append(directory / file["name"]) + parsed = read_dsc(dsc) + name = parsed["source"] + version = parsed["version"] + for file in parsed["files"]: + files.append(directory / file["name"]) return SourcePackage.create(name=name, version=version, files=files) diff -Nru debusine-0.11.3/debusine/db/models/artifacts.py debusine-0.11.3+deb13u1/debusine/db/models/artifacts.py --- debusine-0.11.3/debusine/db/models/artifacts.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/db/models/artifacts.py 2026-06-09 08:52:48.000000000 -0400 @@ -520,6 +520,21 @@ artifact__workspace__in=workspaces, target__workspace__in=workspaces ) + @permission_filter() + def can_delete(self, user: PermissionUser) -> "ArtifactRelationQuerySet[A]": + """Keep only ArtifactRelations that can be deleted.""" + # Delegate to workspace OWNERS + owned_workspaces = Workspace.objects.none() + if user and user.is_authenticated: + owned_workspaces = Workspace.objects.with_role( + user, Workspace.Roles.OWNER + ) + visible_workspaces = Workspace.objects.can_display(user) + return self.filter( + artifact__workspace__in=owned_workspaces, + target__workspace__in=visible_workspaces, + ) + class ArtifactRelationManager(models.Manager["ArtifactRelation"]): """Manager for the ArtifactRelation model.""" @@ -568,3 +583,12 @@ return self.artifact.workspace.can_display( user ) and self.target.workspace.can_display(user) + + @permission_check( + "{user} cannot delete artifact relation {resource}", + ) + def can_delete(self, user: PermissionUser) -> bool: + """Check if the user can delete artifacts relations.""" + return self.artifact.workspace.can_configure(user) and self.can_display( + user + ) diff -Nru debusine-0.11.3/debusine/db/models/tests/test_artifacts.py debusine-0.11.3+deb13u1/debusine/db/models/tests/test_artifacts.py --- debusine-0.11.3/debusine/db/models/tests/test_artifacts.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/db/models/tests/test_artifacts.py 2026-06-09 08:52:48.000000000 -0400 @@ -627,9 +627,8 @@ def setUp(self) -> None: """Initialize test object.""" self.artifact_1, _ = self.playground.create_artifact() - self.artifact_2 = Artifact.objects.create( - workspace=self.artifact_1.workspace, - category=self.artifact_1.category, + self.artifact_2, _ = self.playground.create_artifact( + workspace=self.playground.create_workspace(name="other-workspace") ) def test_type_not_valid_raise_validation_error(self) -> None: @@ -666,7 +665,10 @@ ) for allowed_artifact in (self.artifact_1, self.artifact_2): with override_permission( - Workspace, "can_display", ListFilter, include=[allowed_artifact] + Workspace, + "can_display", + ListFilter, + include=[allowed_artifact.workspace], ): self.assertPermission( "can_display", @@ -679,3 +681,47 @@ users=(AnonymousUser(), self.scenario.user), denied=artifact_relation, ) + + def test_can_delete(self) -> None: + """Test the can_delete predicate.""" + artifact_relation = self.playground.create_artifact_relation( + artifact=self.artifact_1, target=self.artifact_2 + ) + # Requires OWNER access in the artifact's workspace: + with ( + self.subTest(workspace_owner=False, can_display=True), + override_permission(Workspace, "can_display", DenyAll), + ): + self.assertPermission( + "can_delete", + users=(AnonymousUser(), self.scenario.user), + denied=artifact_relation, + ) + + self.playground.create_group_role( + self.artifact_1.workspace, + Workspace.Roles.OWNER, + users=[self.scenario.user], + ) + + # Also requires can_display + with self.subTest(can_display=False): + self.assertPermission( + "can_delete", + users=(AnonymousUser(), self.scenario.user), + denied=artifact_relation, + ) + with ( + self.subTest(can_display=True), + override_permission(Workspace, "can_display", AllowAll), + ): + self.assertPermission( + "can_delete", + users=AnonymousUser(), + denied=artifact_relation, + ) + self.assertPermission( + "can_delete", + users=self.scenario.user, + allowed=artifact_relation, + ) diff -Nru debusine-0.11.3/debusine/server/views/artifacts.py debusine-0.11.3+deb13u1/debusine/server/views/artifacts.py --- debusine-0.11.3/debusine/server/views/artifacts.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/server/views/artifacts.py 2026-06-09 08:52:48.000000000 -0400 @@ -14,7 +14,7 @@ import tempfile from collections.abc import Mapping from pathlib import Path -from typing import Any, IO, Literal +from typing import Any, IO, Literal, override from django.conf import settings from django.http import Http404 @@ -382,6 +382,8 @@ ) assert isinstance(artifact, Artifact) + self.enforce(artifact.workspace.can_create_artifacts) + try: return FileInArtifact.objects.get(artifact=artifact, path=file_path) except FileInArtifact.DoesNotExist: @@ -494,6 +496,7 @@ parser_classes = [JSONParser] pagination_class = None + @override def get_queryset(self) -> ArtifactRelationQuerySet[Any]: """Get the query set for this view.""" queryset = ArtifactRelation.objects.in_current_scope() @@ -528,6 +531,7 @@ return queryset.filter(**filter_kwargs) + @override def perform_create( self, serializer: BaseSerializer[ArtifactRelation] ) -> None: @@ -535,8 +539,13 @@ artifact = serializer.validated_data["artifact"] target = serializer.validated_data["target"] self.set_current_workspace(artifact.workspace) - # TODO: This needs to be based on some kind of write permission - # rather than on can_display. self.enforce(artifact.can_display) self.enforce(target.can_display) + self.enforce(artifact.workspace.can_create_artifacts) super().perform_create(serializer) + + @override + def perform_destroy(self, instance: ArtifactRelation) -> None: + """Delete an artifact relation.""" + self.enforce(instance.can_delete) + super().perform_destroy(instance) diff -Nru debusine-0.11.3/debusine/server/views/tests/test_artifacts.py debusine-0.11.3+deb13u1/debusine/server/views/tests/test_artifacts.py --- debusine-0.11.3/debusine/server/views/tests/test_artifacts.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/server/views/tests/test_artifacts.py 2026-06-09 08:52:48.000000000 -0400 @@ -46,9 +46,11 @@ from debusine.server.serializers import ArtifactSerializerResponse from debusine.server.views.artifacts import UploadFileView from debusine.test.django import ( + AllowAll, JSONResponseProtocol, TestCase, TestResponseType, + override_permission, ) from debusine.test.test_utils import data_generator @@ -1023,7 +1025,13 @@ def test_put_file_user_token(self) -> None: """A request with a user token can PUT a file.""" token = self.playground.create_user_token() + assert token.user + self.playground.create_group_role( + self.playground.get_default_workspace(), + Workspace.Roles.CONTRIBUTOR, + users=[token.user], + ) response = self.put_file(token=token) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -1497,7 +1505,7 @@ token = self.playground.create_user_token() assert token.user is not None self.playground.create_group_role( - private_workspace, Workspace.Roles.OWNER, users=[token.user] + private_workspace, Workspace.Roles.CONTRIBUTOR, users=[token.user] ) self.assertEqual(FileInStore.objects.count(), 0) @@ -1703,6 +1711,11 @@ category="test", workspace=self.scenario.workspace ) self.client = APIClient() + self.playground.create_group_role( + self.scenario.workspace, + Workspace.Roles.CONTRIBUTOR, + users=[self.scenario.user], + ) @staticmethod def request_relations( @@ -2014,7 +2027,9 @@ "type": ArtifactRelation.Relations.RELATES_TO, } - response = self.post_relation(body=artifact_relation) + response = self.post_relation( + body=artifact_relation, token=self.scenario.user_token + ) self.assertResponseProblem( response, @@ -2025,7 +2040,9 @@ def test_post_400_cannot_deserialize(self) -> None: """Add a new relation for an artifact: HTTP 400 cannot deserialize.""" - response = self.post_relation(body={"foo": "bar"}) + response = self.post_relation( + body={"foo": "bar"}, token=self.scenario.user_token + ) self.assertResponseProblem( response, @@ -2042,7 +2059,9 @@ "type": ArtifactRelation.Relations.RELATES_TO, } - response = self.post_relation(body=relation) + response = self.post_relation( + body=relation, token=self.scenario.user_token + ) assert isinstance(response, JSONResponseProtocol) response_data = response.json() @@ -2073,13 +2092,44 @@ "type": str(artifact_relation.type), } - response = self.post_relation(body=relation) + response = self.post_relation( + body=relation, token=self.scenario.user_token + ) self.assertEqual(response.status_code, status.HTTP_200_OK) relation["id"] = artifact_relation.id assert isinstance(response, JSONResponseProtocol) self.assertEqual(response.json(), relation) + def test_anonymous_creation(self) -> None: + """Anonymous users can't create relations.""" + relation = { + "artifact": self.artifact.id, + "target": self.artifact_target.id, + "type": ArtifactRelation.Relations.RELATES_TO, + } + + response = self.post_relation(body=relation, token=None) + self.assertResponseProblem( + response, + ( + f"AnonymousUser cannot create artifacts in " + f"{self.scenario.workspace}" + ), + status_code=status.HTTP_403_FORBIDDEN, + ) + + def test_worker_token_createn(self) -> None: + """Worker tokens grant create access.""" + token = self.playground.create_worker_token() + artifact_relation = { + "artifact": self.artifact.id, + "target": self.artifact_target.id, + "type": ArtifactRelation.Relations.RELATES_TO, + } + response = self.post_relation(body=artifact_relation, token=token) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_post_400_invalid_target_id(self) -> None: """No relations added to the artifact: invalid target id.""" self.assertEqual(self.artifact.relations.all().count(), 0) @@ -2090,7 +2140,9 @@ "type": ArtifactRelation.Relations.RELATES_TO, } - response = self.post_relation(body=relation) + response = self.post_relation( + body=relation, token=self.scenario.user_token + ) self.assertResponseProblem( response, @@ -2105,6 +2157,10 @@ workspace = self.playground.create_workspace( scope=scope1, name="workspace", public=True ) + self.playground.create_group_role( + workspace, Workspace.Roles.CONTRIBUTOR, users=[self.scenario.user] + ) + self.artifact.workspace = workspace self.artifact.save() self.artifact_target.workspace = workspace @@ -2212,11 +2268,39 @@ type=ArtifactRelation.Relations.RELATES_TO, ) - response = self.delete_relation(artifact_relation.id) + self.playground.create_group_role( + self.artifact.workspace, + Workspace.Roles.OWNER, + users=[self.scenario.user], + ) + + response = self.delete_relation( + artifact_relation.id, token=self.scenario.user_token + ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(self.artifact.relations.all().count(), 0) + def test_delete_unauthorized(self) -> None: + """Delete relation without can_delete.""" + artifact_relation = ArtifactRelation.objects.create( + artifact=self.artifact, + target=self.artifact_target, + type=ArtifactRelation.Relations.RELATES_TO, + ) + + response = self.delete_relation( + artifact_relation.id, token=self.scenario.user_token + ) + self.assertResponseProblem( + response, + ( + f"{self.scenario.user} cannot delete artifact relation " + f"{artifact_relation}" + ), + status_code=status.HTTP_403_FORBIDDEN, + ) + def test_delete_honours_scope(self) -> None: """Deleting relations looks up the artifact in the current scope.""" scope1 = self.playground.get_or_create_scope("scope1") @@ -2224,6 +2308,9 @@ workspace = self.playground.create_workspace( scope=scope1, name="workspace", public=True ) + self.enterContext( + override_permission(ArtifactRelation, "can_delete", AllowAll) + ) self.artifact.workspace = workspace self.artifact.save() self.artifact_target.workspace = workspace diff -Nru debusine-0.11.3/debusine/tasks/assemble_signed_source.py debusine-0.11.3+deb13u1/debusine/tasks/assemble_signed_source.py --- debusine-0.11.3/debusine/tasks/assemble_signed_source.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/assemble_signed_source.py 2026-06-09 08:52:48.000000000 -0400 @@ -20,7 +20,6 @@ from typing import Any from debian.changelog import Changelog -from debian.deb822 import Changes from debusine.artifacts import ( BinaryPackage, @@ -42,6 +41,7 @@ AssembleSignedSourceDynamicData, ) from debusine.tasks.server import TaskDatabaseInterface +from debusine.utils import is_single_path_component, read_changes # https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-source _re_package_name = re.compile(r"^[a-z0-9][a-z0-9+.-]+$") @@ -447,10 +447,13 @@ self.executor_instance.file_pull( remote_changes_path, local_changes_path ) - with open(local_changes_path) as changes_file: - changes = Changes(changes_file) + changes = read_changes(local_changes_path) local_source_package_paths: list[Path] = [] for checksum in changes["Files"]: + # This was the result of running dpkg-genchanges ourselves, so + # we can safely assert that it's reasonably well formed. It's + # also checked by read_changes. + assert is_single_path_component(checksum["name"]) local_source_package_paths.append( local_changes_path.parent / checksum["name"] ) diff -Nru debusine-0.11.3/debusine/tasks/autopkgtest.py debusine-0.11.3+deb13u1/debusine/tasks/autopkgtest.py --- debusine-0.11.3/debusine/tasks/autopkgtest.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/autopkgtest.py 2026-06-09 08:52:48.000000000 -0400 @@ -345,13 +345,10 @@ # Used by upload_artifacts() dsc_file = download_directory / self._source_package_path - dsc = utils.read_dsc(dsc_file) - - if dsc is None: - self.append_to_log_file( - "configure_for_execution.log", - [f"{self._source_package_path} is not a valid .dsc file"], - ) + try: + dsc = utils.read_dsc(dsc_file) + except ValueError as e: + self.append_to_log_file("configure_for_execution.log", [str(e)]) return False self._source_package_name = dsc["source"] diff -Nru debusine-0.11.3/debusine/tasks/lintian.py debusine-0.11.3+deb13u1/debusine/tasks/lintian.py --- debusine-0.11.3/debusine/tasks/lintian.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/lintian.py 2026-06-09 08:52:48.000000000 -0400 @@ -450,13 +450,10 @@ package_type = LintianPackageType.BINARY_ANY elif file.suffix in ".dsc": - dsc = utils.read_dsc(file) - - if dsc is None: - self.append_to_log_file( - "configure_for_execution.log", - [f"{file} is not a valid .dsc file"], - ) + try: + dsc = utils.read_dsc(file) + except ValueError as e: + self.append_to_log_file("configure_for_execution.log", [str(e)]) return None, None # Lintian identifies the binary package Vs. source package with diff -Nru debusine-0.11.3/debusine/tasks/mergeuploads.py debusine-0.11.3+deb13u1/debusine/tasks/mergeuploads.py --- debusine-0.11.3/debusine/tasks/mergeuploads.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/mergeuploads.py 2026-06-09 08:52:48.000000000 -0400 @@ -28,6 +28,7 @@ from debusine.tasks import BaseExternalTask from debusine.tasks.models import MergeUploadsData, MergeUploadsDynamicData from debusine.tasks.server import TaskDatabaseInterface +from debusine.utils import read_changes class MergeUploadsError(Exception): @@ -132,12 +133,6 @@ return True @staticmethod - def _read_changes(path: Path) -> Changes: - """Read a .changes file.""" - with open(path) as f: - return Changes(f) - - @staticmethod def _check_simple_fields(all_changes: Sequence[Changes]) -> None: """Check whether simple fields in some .changes files are consistent.""" if all_changes[0]["Format"] != "1.8": @@ -259,7 +254,7 @@ def run(self, execute_directory: Path) -> bool: # noqa: U100 """Do the main work of the task.""" - all_changes = [self._read_changes(path) for path in self._changes_paths] + all_changes = [read_changes(path) for path in self._changes_paths] merged = self.merge_changes(all_changes) self._upload_artifact = self.make_upload_artifact(merged) return True diff -Nru debusine-0.11.3/debusine/tasks/sbuild.py debusine-0.11.3+deb13u1/debusine/tasks/sbuild.py --- debusine-0.11.3/debusine/tasks/sbuild.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/sbuild.py 2026-06-09 08:52:48.000000000 -0400 @@ -16,6 +16,7 @@ import email.utils import os +import shlex from pathlib import Path from typing import Any, cast @@ -338,10 +339,11 @@ if self.supports_deb822_sources(self._environment_codename()): # sbuild has no native deb822 support, yet: #1089735 for path in self.extra_repository_sources: + inner_path = f"/etc/apt/sources.list.d/{path.name}" + inner_command = f"cat > {shlex.quote(inner_path)}" args.append( - f"--pre-build-commands=cat {path} | " - f"%SBUILD_CHROOT_EXEC sh -c 'cat > " - f"/etc/apt/sources.list.d/{path.name}'" + f"--pre-build-commands=cat {shlex.quote(str(path))} | " + f"%SBUILD_CHROOT_EXEC sh -c {shlex.quote(inner_command)}" ) else: for source in self.iter_oneline_sources(): @@ -349,10 +351,13 @@ # apt < 2.3.10 has no support for keys embedded in Signed-By for path in self.extra_repository_keys: + inner_path = f"/etc/apt/keyrings/{path.name}" + inner_command = ( + f"mkdir -p /etc/apt/keyrings && cat > {shlex.quote(inner_path)}" + ) args.append( - f"--pre-build-commands=cat {path} | " - f"%SBUILD_CHROOT_EXEC sh -c 'mkdir -p /etc/apt/keyrings && " - f"cat > /etc/apt/keyrings/{path.name}'" + f"--pre-build-commands=cat {shlex.quote(str(path))} | " + f"%SBUILD_CHROOT_EXEC sh -c {shlex.quote(inner_command)}" ) return args @@ -685,41 +690,46 @@ if not self.debusine: raise AssertionError("self.debusine not set") + if self._dsc_file is None: + return + # ValueError should be impossible here, since it would already have + # been caught by + # SbuildValidatorMixin.check_directory_for_consistency_errors. If + # it somehow happens anyway, just let it propagate up. dsc = read_dsc(self._dsc_file) - if dsc is not None: - # Upload the .build file (PackageBuildLog) - remote_build_log = self._upload_package_build_log( - directory, dsc["source"], dsc["version"], execution_success - ) - - if remote_build_log is not None: - for source_artifact_id in self._source_artifacts_ids: - self.debusine.relation_create( - remote_build_log.id, - source_artifact_id, - RelationType.RELATES_TO, - ) + # Upload the .build file (PackageBuildLog) + remote_build_log = self._upload_package_build_log( + directory, dsc["source"], dsc["version"], execution_success + ) - if execution_success: - # Upload the *.deb/*.udeb files (BinaryPackages) - remote_binary_packages = self._upload_binary_packages( - directory, dsc + if remote_build_log is not None: + for source_artifact_id in self._source_artifacts_ids: + self.debusine.relation_create( + remote_build_log.id, + source_artifact_id, + RelationType.RELATES_TO, ) - # Upload the .changes and the rest of the files - remote_binary_changes = self._upload_binary_upload(directory) - - # Upload the .changes on its own as signing input - remote_signing_input = self._upload_signing_input(directory) - - # Create the relations - self._create_remote_binary_packages_relations( - remote_build_log, - remote_binary_changes, - remote_binary_packages, - remote_signing_input, - ) + if execution_success: + # Upload the *.deb/*.udeb files (BinaryPackages) + remote_binary_packages = self._upload_binary_packages( + directory, dsc + ) + + # Upload the .changes and the rest of the files + remote_binary_changes = self._upload_binary_upload(directory) + + # Upload the .changes on its own as signing input + remote_signing_input = self._upload_signing_input(directory) + + # Create the relations + self._create_remote_binary_packages_relations( + remote_build_log, + remote_binary_changes, + remote_binary_packages, + remote_signing_input, + ) def get_label(self) -> str: """Return the task label.""" diff -Nru debusine-0.11.3/debusine/tasks/sbuild_validator_mixin.py debusine-0.11.3+deb13u1/debusine/tasks/sbuild_validator_mixin.py --- debusine-0.11.3/debusine/tasks/sbuild_validator_mixin.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/sbuild_validator_mixin.py 2026-06-09 08:52:48.000000000 -0400 @@ -8,12 +8,18 @@ # contained in the LICENSE file. """Mixin to validate a sbuild build.""" -from pathlib import Path + +from pathlib import Path, PurePath from debian.deb822 import Deb822 -import debusine.utils -from debusine.utils import calculate_hash, read_dsc +from debusine.utils import ( + calculate_hash, + find_file_suffixes, + find_files_suffixes, + read_changes, + read_dsc, +) class SbuildValidatorMixin: @@ -41,7 +47,7 @@ # Files ending in .dsc, .changes or .build: exist in the directory # but are not taken in account for the checks: remove them from # files_in_directory - remove_from_files_in_directory = debusine.utils.find_files_suffixes( + remove_from_files_in_directory = find_files_suffixes( build_directory, [".dsc", ".changes", ".build"], include_symlinks=True, @@ -77,11 +83,14 @@ errors: list[str], files_in_directory: set[Path], ) -> None: - dsc_file = debusine.utils.find_file_suffixes(build_directory, [".dsc"]) - - dsc = read_dsc(dsc_file) + dsc_path = find_file_suffixes(build_directory, [".dsc"]) + if dsc_path is None: + return - if dsc is None: + try: + dsc = read_dsc(dsc_path) + except ValueError as e: + errors.append(str(e)) return cls._validate_deb822_file( @@ -95,9 +104,14 @@ errors: list[str], files_in_directory: set[Path], ) -> None: - changes = debusine.utils.read_changes(build_directory) + changes_path = find_file_suffixes(build_directory, [".changes"]) + if changes_path is None: + return - if changes is None: + try: + changes = read_changes(changes_path) + except ValueError as e: + errors.append(str(e)) return cls._validate_deb822_file( @@ -130,12 +144,18 @@ section_name = hash_algorithm_to_section[hash_algorithm] for file_in_changes in contents[section_name]: + # Checked by read_changes. + assert ( + PurePath(file_in_changes["name"]).name + == file_in_changes["name"] + ) + file = build_directory / file_in_changes["name"] if not file.is_file(): errors.append( - f'File in .changes section {section_name} ' - f'does not exist: "{file.name}"' + f"File in .changes section {section_name}" + f" does not exist: {file.name!r}" ) continue diff -Nru debusine-0.11.3/debusine/tasks/tests/test_autopkgtest.py debusine-0.11.3+deb13u1/debusine/tasks/tests/test_autopkgtest.py --- debusine-0.11.3/debusine/tasks/tests/test_autopkgtest.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/tests/test_autopkgtest.py 2026-06-09 08:52:48.000000000 -0400 @@ -1121,7 +1121,7 @@ / "configure_for_execution.log" ).read_text() self.assertEqual( - log_file_contents, "hello.dsc is not a valid .dsc file\n" + log_file_contents, "'hello.dsc' does not contain Source field\n" ) def setup_task_succeeded( diff -Nru debusine-0.11.3/debusine/tasks/tests/test_lintian.py debusine-0.11.3+deb13u1/debusine/tasks/tests/test_lintian.py --- debusine-0.11.3/debusine/tasks/tests/test_lintian.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/tests/test_lintian.py 2026-06-09 08:52:48.000000000 -0400 @@ -1671,7 +1671,9 @@ Path(self.task._debug_log_files_directory.name) / "configure_for_execution.log" ).read_text() - self.assertEqual(log_file_contents, f"{dsc} is not a valid .dsc file\n") + self.assertEqual( + log_file_contents, f"{dsc.name!r} does not contain Source field\n" + ) def test_label_dynamic_data_is_none(self) -> None: """Test get_label if dynamic_data.subject is None.""" diff -Nru debusine-0.11.3/debusine/tasks/tests/test_mergeuploads.py debusine-0.11.3+deb13u1/debusine/tasks/tests/test_mergeuploads.py --- debusine-0.11.3/debusine/tasks/tests/test_mergeuploads.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/tests/test_mergeuploads.py 2026-06-09 08:52:48.000000000 -0400 @@ -37,6 +37,7 @@ ) from debusine.test import TestCase from debusine.test.test_utils import create_artifact_response +from debusine.utils import read_changes class MergeUploadsTests(ExternalTaskHelperMixin[MergeUploads], TestCase): @@ -582,6 +583,40 @@ ] ) + def test_execute_checksum_multiple_path_components(self) -> None: + self.task.work_request_id = 2 + self.task.workspace_name = "testing" + self.task.dynamic_data = MergeUploadsDynamicData(input_uploads_ids=[1]) + download_directory = self.create_temporary_directory() + + debusine_mock = self.mock_debusine() + debusine_mock.lookup_single.return_value = LookupSingleResponse( + result_type=LookupResultType.ARTIFACT, artifact=1 + ) + debusine_mock.download_artifact.return_value = True + + f_changes = download_directory / "hello_1.0_all.changes" + changes = deb822.Changes( + { + "Format": "1.8", + "Source": "hello", + "Version": "1.0", + "Checksums-Sha256": "\n 0000 1 ../file", + } + ) + f_changes.write_text(changes.dump()) + + self.assertTrue(self.task.configure_for_execution(download_directory)) + self.assertEqual(self.task._changes_paths, [f_changes]) + + execute_directory = self.create_temporary_directory() + with self.assertRaisesRegex( + ValueError, + r"'hello_1\.0_all\.changes' has an entry in Checksums-Sha256 with" + r" more than one path component: '\.\./file'", + ): + self.task.run(execute_directory) + def test_upload_artifacts(self) -> None: """upload_artifact() and relation_create() is called.""" self.task.dynamic_data = MergeUploadsDynamicData( @@ -600,7 +635,7 @@ self.task._changes_paths = [f_in2, f_in1] f_merged = self.create_temporary_file() self.write_changes_file(f_merged, [f_deb]) - merged = deb822.Changes(f_merged.read_text()) + merged = read_changes(f_merged) self.task._upload_artifact = self.task.make_upload_artifact(merged) # Debusine.upload_artifact is mocked to verify the call only diff -Nru debusine-0.11.3/debusine/tasks/tests/test_sbuild.py debusine-0.11.3+deb13u1/debusine/tasks/tests/test_sbuild.py --- debusine-0.11.3/debusine/tasks/tests/test_sbuild.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/tests/test_sbuild.py 2026-06-09 08:52:48.000000000 -0400 @@ -2239,7 +2239,6 @@ ) dsc = utils.read_dsc(dsc_file) - assert dsc is not None debusine_mock = self.patch_sbuild_debusine() @@ -2395,7 +2394,6 @@ dsc_data = self.write_dsc_example_file(dsc_file) dsc = utils.read_dsc(dsc_file) - assert dsc is not None package_file = build_directory / "package_1.0_amd64.deb" self.write_deb_file( diff -Nru debusine-0.11.3/debusine/tasks/tests/test_sbuild_validator_mixin.py debusine-0.11.3+deb13u1/debusine/tasks/tests/test_sbuild_validator_mixin.py --- debusine-0.11.3/debusine/tasks/tests/test_sbuild_validator_mixin.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/tasks/tests/test_sbuild_validator_mixin.py 2026-06-09 08:52:48.000000000 -0400 @@ -10,6 +10,7 @@ """Unit tests for the SbuildValidatorMixin class.""" from pathlib import Path +from textwrap import dedent from debusine.tasks.models import BaseDynamicTaskData, BaseTaskData from debusine.tasks.sbuild_validator_mixin import SbuildValidatorMixin @@ -54,7 +55,7 @@ changes_file = sbuild_directory / "hello.changes" - (sbuild_directory / "hello.dsc").write_text("") + self.write_dsc_file(sbuild_directory / "hello.dsc", []) self.write_changes_file(changes_file, [file1, file2]) @@ -75,6 +76,38 @@ sbuild_directory ) + def test_check_directory_for_consistency_errors_multiple_path_components( + self, + ) -> None: + sbuild_directory = self.create_temporary_directory() + changes_file = sbuild_directory / "hello.changes" + changes_file.write_text( + dedent( + """\ + Format: 1.8 + Source: hello + Version: 1.0 + Files: + 0000 1 devel optional ../file + Checksums-Sha1: + 0000 1 ../file + Checksums-Sha256: + 0000 1 ../file + """ + ) + ) + expected_errors = [ + "'hello.changes' has an entry in Files with more than one path" + " component: '../file'" + ] + + self.assertEqual( + self.sbuild_validator_mixin.check_directory_for_consistency_errors( + sbuild_directory + ), + expected_errors, + ) + def test_check_directory_for_consistency_errors_missing_file(self) -> None: """check_directory_for_consistency_errors() return one file missing.""" sbuild_directory = self.create_temporary_directory() @@ -89,11 +122,8 @@ file2.unlink() expected_errors = [ - 'File in .changes section Checksums-Sha1 ' - 'does not exist: "file2.deb"', - 'File in .changes section Checksums-Sha256 ' - 'does not exist: "file2.deb"', - 'File in .changes section Files does not exist: "file2.deb"', + f"File in .changes section {section} does not exist: 'file2.deb'" + for section in ("Checksums-Sha1", "Checksums-Sha256", "Files") ] self.assertEqual( @@ -127,6 +157,23 @@ [], ) + def test_check_directory_for_consistency_errors_bad_dsc_file(self) -> None: + sbuild_directory = self.create_temporary_directory() + + (dsc_file := sbuild_directory / "hello.dsc").write_text("") + + changes_file = sbuild_directory / "hello.changes" + self.write_changes_file(changes_file, [dsc_file]) + + expected_errors = [f"{dsc_file.name!r} does not contain Source field"] + + self.assertEqual( + self.sbuild_validator_mixin.check_directory_for_consistency_errors( + sbuild_directory + ), + expected_errors, + ) + def test_check_directory_for_consistency_errors_invalid_hash(self) -> None: """ check_directory_for_consistency_errors() return an error. diff -Nru debusine-0.11.3/debusine/utils/__init__.py debusine-0.11.3+deb13u1/debusine/utils/__init__.py --- debusine-0.11.3/debusine/utils/__init__.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/utils/__init__.py 2026-06-09 08:52:48.000000000 -0400 @@ -14,7 +14,7 @@ import shutil from collections.abc import Callable, Generator, Mapping, Sequence from enum import StrEnum -from pathlib import Path +from pathlib import Path, PurePath from types import GenericAlias from typing import ( Any, @@ -131,40 +131,52 @@ raise ValueError(_error_message_invalid_header(header_name, header_value)) -def read_dsc(dsc_path: Path | None) -> deb822.Dsc | None: - """ - If dsc_path is not None: read the file and return the contents. +def is_single_path_component(value: str) -> bool: + """Return whether ``value`` is a single path component.""" + return PurePath(value).name == value + + +def _check_deb822_security(path: Path, data: deb822.Deb822) -> None: + """Check that a deb822 file does not reference files outside the CWD.""" + for field in ("Files", "Checksums-Sha1", "Checksums-Sha256"): + for file in data.get(field, []): + if not is_single_path_component(file["name"]): + raise ValueError( + f"{path.name!r} has an entry in {field} with more than one" + f" path component: {file['name']!r}" + ) + - If the dsc does not have at least "source" and "version" return None. +def read_dsc(dsc_path: Path) -> deb822.Dsc: """ - if dsc_path is None: - return None + Read a .dsc file and return the contents. + :raises ValueError: if the dsc does not have at least "source" and + "version". + """ with open(dsc_path) as dsc_file: dsc = deb822.Dsc(dsc_file) - if "source" in dsc and "version" in dsc: - # At least "source" and "version" must exist to be a valid - # dsc file in the context of Sbuild task. - return dsc + if "source" not in dsc: + raise ValueError(f"{dsc_path.name!r} does not contain Source field") + if "version" not in dsc: + raise ValueError( + f"{dsc_path.name!r} does not contain Version field" + ) - return None + _check_deb822_security(dsc_path, dsc) + return dsc -def read_changes(build_directory: Path) -> deb822.Changes | None: - """ - Find the file .changes in build_directory, read and return it. - - If the changes file does not exist, return None. - """ - changes_path = find_file_suffixes(build_directory, [".changes"]) - - if changes_path is None: - return None +def read_changes(changes_path: Path) -> deb822.Changes: + """Read a .changes file and return the contents.""" with open(changes_path) as changes_file: changes = deb822.Changes(changes_file) - return changes + + _check_deb822_security(changes_path, changes) + + return changes def find_files_suffixes( diff -Nru debusine-0.11.3/debusine/utils/tests/test_utils.py debusine-0.11.3+deb13u1/debusine/utils/tests/test_utils.py --- debusine-0.11.3/debusine/utils/tests/test_utils.py 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/debusine/utils/tests/test_utils.py 2026-06-09 08:52:48.000000000 -0400 @@ -111,38 +111,79 @@ def test_read_dsc_file(self) -> None: """_read_dsc return a deb822.Dsc object: it was a valid Dsc file.""" - dsc_file = self.create_temporary_file() + dsc_file = self.create_temporary_file(suffix=".dsc") self.write_dsc_example_file(dsc_file) dsc = read_dsc(dsc_file) self.assertIsInstance(dsc, deb822.Dsc) - def test_read_dsc_file_invalid(self) -> None: - """_read_dsc return None: it was a not a valid Dsc file.""" + def test_read_dsc_file_no_source_field(self) -> None: dsc_file = self.create_temporary_file(contents=b"invalid dsc file") - dsc = read_dsc(dsc_file) - - self.assertIsNone(dsc) + with self.assertRaisesRegex( + ValueError, f"{dsc_file.name!r} does not contain Source field" + ): + read_dsc(dsc_file) + + def test_read_dsc_file_no_version_field(self) -> None: + dsc_file = self.create_temporary_file(contents=b"Source: hello\n") + + with self.assertRaisesRegex( + ValueError, f"{dsc_file.name!r} does not contain Version field" + ): + read_dsc(dsc_file) + + def test_read_dsc_file_multiple_path_components(self) -> None: + dsc_file = self.create_temporary_file(suffix=".dsc") + + for field, data in ( + ("Files", {"Files": "\n 0000 1 ../file"}), + ("Checksums-Sha1", {"Checksums-Sha1": "\n 0000 1 ../file"}), + ("Checksums-Sha256", {"Checksums-Sha256": "\n 0000 1 ../file"}), + ): + with self.subTest(field=field): + with dsc_file.open("w") as f: + deb822.Dsc( + {"Source": "hello", "Version": "1.0", **data} + ).dump(f, text_mode=True) + + with self.assertRaisesRegex( + ValueError, + rf"{dsc_file.name!r} has an entry in {field} with more" + rf" than one path component: '\.\./file'", + ): + read_dsc(dsc_file) def test_read_changes_file(self) -> None: """read_changes returns deb822.Changes for a valid .changes file.""" - build_directory = self.create_temporary_directory() - changes_file = build_directory / "foo.changes" + changes_file = self.create_temporary_file(suffix=".changes") self.write_changes_file(changes_file, []) - changes = read_changes(build_directory) + changes = read_changes(changes_file) self.assertIsInstance(changes, deb822.Changes) - def test_read_changes_file_missing(self) -> None: - """read_changes returns None if the directory has no .changes file.""" - build_directory = self.create_temporary_directory() - - changes = read_changes(build_directory) + def test_read_changes_file_multiple_path_components(self) -> None: + changes_file = self.create_temporary_file(suffix=".changes") - self.assertIsNone(changes) + for field, data in ( + ("Files", {"Files": "\n 0000 1 devel optional ../file"}), + ("Checksums-Sha1", {"Checksums-Sha1": "\n 0000 1 ../file"}), + ("Checksums-Sha256", {"Checksums-Sha256": "\n 0000 1 ../file"}), + ): + with self.subTest(field=field): + with changes_file.open("w") as f: + deb822.Dsc( + {"Source": "hello", "Version": "1.0", **data} + ).dump(f, text_mode=True) + + with self.assertRaisesRegex( + ValueError, + rf"{changes_file.name!r} has an entry in {field} with more" + rf" than one path component: '\.\./file'", + ): + read_changes(changes_file) def test_find_file_suffixes_multiple_files(self) -> None: """find_file_suffixes() return two files with different suffixes.""" diff -Nru debusine-0.11.3/docs/reference/release-history.rst debusine-0.11.3+deb13u1/docs/reference/release-history.rst --- debusine-0.11.3/docs/reference/release-history.rst 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/docs/reference/release-history.rst 2026-06-09 08:52:48.000000000 -0400 @@ -6,6 +6,38 @@ .. towncrier release notes start +.. _release-0.11.3+deb13u1: + +debusine 0.11.3+deb13u1 (2026-06-09) +------------------------------------ + +Server +~~~~~~ + +Bug Fixes +^^^^^^^^^ + +- Enforce permissions on the file body upload endpoint. (Note that hashes were + already recorded in the artifact creation step, which included permission + checks.) (`#1245 + <https://salsa.debian.org/freexian-team/debusine/-/issues/1245>`__) +- Artifact Relation Creation endpoint: Require the caller to have permission to + create artifacts in the workspace that the origin artifact resides in. + Artifact Relation Deletion endpoint: Require the caller to be an owner of the + origin artifact's workspace. + + +Tasks +~~~~~ + +Bug Fixes +^^^^^^^^^ + +- Reject checksum filenames in ``.dsc`` and ``.changes`` files that contain + multiple path components. (`#1484 + <https://salsa.debian.org/freexian-team/debusine/-/issues/1484>`__) + + .. _release-0.11.3: 0.11.3 (2025-07-08) diff -Nru debusine-0.11.3/.gitignore debusine-0.11.3+deb13u1/.gitignore --- debusine-0.11.3/.gitignore 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/.gitignore 1969-12-31 20:00:00.000000000 -0400 @@ -1,26 +0,0 @@ -*.py[cod] - -*.egg -*.egg-info -*.so - -*.swp - -/.mypy_cache -/.tox -/.ropeproject -/.idea -/MANIFEST - -/build/ - -/data - -/debusine/project/settings/local.py -/debusine/signing/settings/local.py - -/docs/_build/ - -/.coverage/ -/venv/ -/ve/ diff -Nru debusine-0.11.3/.gitlab-ci.yml debusine-0.11.3+deb13u1/.gitlab-ci.yml --- debusine-0.11.3/.gitlab-ci.yml 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/.gitlab-ci.yml 2026-06-09 08:52:48.000000000 -0400 @@ -6,32 +6,41 @@ # Extend the stages defined by salsa-ci stages: - upstream-tests # Added to run upstream tests first - - provisioning - build - publish - test - deploy # Added to deploy the pages variables: - DROP_PRIVS: 'setpriv --reuid=debusine-test --regid=debusine-test --init-groups --reset-env --' - RELEASE: 'bookworm-backports' + APT_CACHE_ROOT: "$CI_PROJECT_DIR/.cache/apt" + DROP_PRIVS: 'setpriv --reuid=debusine-test --regid=debusine-test --init-groups --reset-env -- env MYPY_CACHE_DIR=$CI_PROJECT_DIR/.cache/mypy XDG_CACHE_HOME=$CI_PROJECT_DIR/.cache' + RELEASE: 'trixie' SALSA_CI_DISABLE_BUILD_PACKAGE_ANY: 1 SALSA_CI_DISABLE_BUILD_PACKAGE_I386: 1 SALSA_CI_DISABLE_CROSSBUILD_ARM64: 1 - SALSA_CI_DISABLE_APTLY: 0 + SALSA_CI_DISABLE_USCAN: 1 SALSA_CI_AUTOPKGTEST_ALLOWED_EXIT_STATUS: '0,2' - SALSA_CI_IMAGES_LINTIAN: ${SALSA_CI_IMAGES}/lintian:bookworm + SALSA_CI_IMAGES_LINTIAN: ${SALSA_CI_IMAGES}/lintian:trixie + XDG_CACHE_HOME: "$CI_PROJECT_DIR/.cache" ## Traditional upstream tests on top of salsa-ci -image: 'debian:bookworm-backports' +image: 'debian:trixie' + +cache: + key: $CI_JOB_NAME + paths: + - .cache .prepare-test: &prepare-test - export LANG=C.UTF-8 - useradd -m debusine-test - "chown -R debusine-test: ." - $DROP_PRIVS env NON_INTERACTIVE=1 bin/quick-setup.sh create_directories - - ci/pin-django-from-bookworm-backports.sh + - $DROP_PRIVS mkdir -p "$CI_PROJECT_DIR/.cache" + - mkdir -p "$APT_CACHE_ROOT/lists/partial" "$APT_CACHE_ROOT/archives/partial" + - (echo "Dir::State::Lists \"$APT_CACHE_ROOT/lists\";"; echo "Dir::Cache::Archives \"$APT_CACHE_ROOT/archives\";") >/etc/apt/apt.conf.d/00gitlab - apt-get update + - apt-get autoclean .before-test: &before-test - *prepare-test @@ -42,11 +51,16 @@ stage: upstream-tests script: - *before-test + # Speculatively update towncrier. This requires complete history. + - apt-get -y install python3-hatch-vcs python3-hatchling towncrier + - $DROP_PRIVS git fetch --unshallow + - $DROP_PRIVS git fetch --tags https://salsa.debian.org/freexian-team/debusine + - if find newsfragments/ -name '*.rst' ! -name release-history-template.rst -type f | grep -q .; then $DROP_PRIVS towncrier build --version "$(hatchling version)" --yes; fi # Generate documentation - $DROP_PRIVS make -C docs html O="-w _build/warnings.txt" - rm -rf docs/_build/doctrees # Save space in generated artifact - "! test -s docs/_build/warnings.txt || (echo 'ERROR: Sphinx generated warnings:' >&2; cat docs/_build/warnings.txt >&2; exit 1)" - - $DROP_PRIVS make -C docs linkcheck + - $DROP_PRIVS make -C docs linkcheck || exit 100 artifacts: expose_as: "generated documentation" paths: @@ -54,17 +68,16 @@ - docs/_build when: always expire_in: 2 weeks - allow_failure: true + allow_failure: + exit_codes: 100 code-linting: stage: upstream-tests dependencies: [] script: - - *before-test - - apt-get install -y git libpq-dev pre-commit - # https://github.com/pypa/setuptools/issues/4519 - - echo 'setuptools<72' >constraints.txt - - $DROP_PRIVS env PIP_CONSTRAINT="$(pwd)/constraints.txt" pre-commit run -a + - *prepare-test + - apt-get install -y git libmagic-dev libpq-dev libsystemd-dev pkg-config pre-commit python3-dev + - $DROP_PRIVS pre-commit run -a --show-diff-on-failure rules: - if: $CI_PIPELINE_SOURCE == "schedule" when: never @@ -107,26 +120,6 @@ .unit-tests-shared-cleanup: &unit-tests-shared-cleanup - service postgresql stop || true -unit-tests-trixie: - <<: *unit-tests-shared - image: "debian:trixie" - variables: - RELEASE: "trixie" - script: - - *prepare-test - - apt-get -y install python3-django - - NON_INTERACTIVE=1 bin/quick-setup.sh install_packages - - *unit-tests-shared-prepare - - $DROP_PRIVS make coverage VERBOSE=1 TOTAL_COVERAGE=1 - - *unit-tests-shared-cleanup - allow_failure: true - rules: - - if: $CI_PIPELINE_SOURCE == "schedule" - when: never - - if: $CI_COMMIT_BRANCH =~ /^debian\/.*-backports$/ - when: never - - when: on_success - unit-tests: <<: *unit-tests-shared <<: *unit-tests-coverage @@ -134,35 +127,14 @@ - *before-test - apt-get -y install python3-hatch-vcs python3-hatchling - *unit-tests-shared-prepare - - $DROP_PRIVS make coverage VERBOSE=1 TOTAL_COVERAGE=1 + - $DROP_PRIVS make coverage VERBOSE=1 TOTAL_COVERAGE=1 HTML_COVERAGE=1 - *unit-tests-shared-cleanup - - "(python3 -m coverage report -m | grep --silent '^TOTAL.*100%$') || (echo 'ERROR: Coverage must be 100%' >&2 ; exit 1)" - hatchling metadata >/dev/null -unit-tests-pip: - <<: *unit-tests-shared - script: - - *prepare-test - - apt-get install -y python3-venv sensible-utils postgresql postgresql-client redis-server graphviz make openssl sbsigntool python3-gpg arch-test dpkg-dev devscripts dput-ng - - $DROP_PRIVS python3 -m venv venv - - $DROP_PRIVS sed -i 's/psycopg2/&-binary/' pyproject.toml - - $DROP_PRIVS venv/bin/pip install -e .[server,client,signing,tests] coverage - # We have to symlink the dput and gpg modules in from the system rather - # than installing them from PyPI. - - $DROP_PRIVS ln -s /usr/lib/python3/dist-packages/dput venv/lib/python*/site-packages/ - - $DROP_PRIVS ln -s /usr/lib/python3/dist-packages/gpg venv/lib/python*/site-packages/ - - *unit-tests-shared-prepare - - $DROP_PRIVS sh -c ". venv/bin/activate; make coverage VERBOSE=1 TOTAL_COVERAGE=1" - - *unit-tests-shared-cleanup - rules: - - if: $CI_COMMIT_BRANCH =~ /^debian\/.*-backports$/ - when: never - - when: on_success - # Pass git-derived version information through to the generated tarball. # This requires complete history. -extract-source: - extends: .provisioning-extract-source +build: + extends: .build-definition before_script: - apt-get update - apt-get -y install python3-hatch-vcs python3-hatchling @@ -171,27 +143,24 @@ - hatchling version >debian/.salsa-ci-hatchling-version variables: SALSA_CI_GBP_BUILDPACKAGE_ARGS: "--git-export=WC" + needs: [] -reprotest: - extends: .test-reprotest +debrebuild: + extends: .test-debrebuild allow_failure: true -autopkgtest: - extends: .test-autopkgtest - parallel: - matrix: - - SALSA_CI_AUTOPKGTEST_ARGS: - - "--setup-commands=ci/pin-django-from-bookworm-backports.sh --test-name=unit-tests-tasks --test-name=unit-tests-client --test-name=unit-tests-worker --test-name=unit-tests-server --test-name=unit-tests-signing --test-name=smoke-test-server" - - "--setup-commands=ci/pin-django-from-bookworm-backports.sh --test-name=integration-tests-generic" - - "--setup-commands=ci/pin-django-from-bookworm-backports.sh --test-name=integration-tests-task-signing" - - "--setup-commands=ci/pin-django-from-bookworm-backports.sh --test-name=integration-tests-tasks-mmdebstrap-autopkgtest-sbuild-lintian-piuparts-blhc-debdiff" - - "--setup-commands=ci/pin-django-from-bookworm-backports.sh --test-name=integration-tests-task-simplesystemimagebuild" - - "--setup-commands=ci/pin-django-from-bookworm-backports.sh --test-name=integration-tests-task-simplesystemimagebuild" +lintian: + extends: .test-lintian + rules: + # The build job sets the distribution to match RELEASE, tripping this + # check. This is harmless unless we're actually uploading to + # -backports. + - if: $CI_COMMIT_BRANCH !~ /^debian\/.*-backports$/ + variables: + SALSA_CI_LINTIAN_SUPPRESS_TAGS: "backports-upload-has-incorrect-version-number,distribution-and-changes-mismatch" -autopkgtest-trixie: +autopkgtest: extends: .test-autopkgtest - variables: - RELEASE: "trixie" parallel: matrix: - SALSA_CI_AUTOPKGTEST_ARGS: @@ -200,37 +169,28 @@ - "--test-name=integration-tests-task-signing" - "--test-name=integration-tests-tasks-mmdebstrap-autopkgtest-sbuild-lintian-piuparts-blhc-debdiff" - "--test-name=integration-tests-task-simplesystemimagebuild" - allow_failure: true - rules: - - if: $CI_COMMIT_BRANCH =~ /^debian\/.*-backports$/ - when: never - - when: on_success + - "--test-name=integration-tests-task-simplesystemimagebuild" piuparts: extends: .test-piuparts - before_script: - - ci/pin-django-from-bookworm-backports.sh - variables: - SALSA_CI_PIUPARTS_PRE_INSTALL_SCRIPT: ci/pin-django-from-bookworm-backports.sh + allow_failure: true -aptly: - extends: .publish-aptly +build source: + extends: .build-source-only variables: - RELEASE: 'bookworm' + DB_BUILD_TYPE: source + SALSA_CI_DISABLE_VERSION_BUMP: 0 pages: stage: deploy dependencies: - unit-tests # To retrieve .coverage artifact - - aptly # To retrieve the apt repository - documentation-linting # To retrieve the documentation script: # Move documentation in the public space - mv docs/_build/html/ public/ # Move coverage report in the public space - mv .coverage/html public/coverage - # Copy the repository created by aptly - - (mkdir public/repository && cd aptly && cp -rfv . ../public/repository) artifacts: paths: - public diff -Nru debusine-0.11.3/.pre-commit-config.yaml debusine-0.11.3+deb13u1/.pre-commit-config.yaml --- debusine-0.11.3/.pre-commit-config.yaml 2025-07-08 10:09:29.000000000 -0400 +++ debusine-0.11.3+deb13u1/.pre-commit-config.yaml 2026-06-09 08:52:48.000000000 -0400 @@ -58,7 +58,7 @@ - flake8-rst-docstrings - flake8-unused-arguments # https://github.com/globality-corp/flake8-logging-format/issues/68 - - setuptools + - setuptools<82 - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: @@ -99,7 +99,7 @@ - email_validator - fabric - fasteners - - hcloud + - hcloud<2.4 - jsonpath-rw - jwcrypto - lxml @@ -129,7 +129,7 @@ - types-lxml - types-paramiko - types-psutil - - types-pygments + - types-pygments<2.19 - types-python-dateutil - types-PyYAML - types-requests

