[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

Reply via email to