# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Unit tests for the APTMirror task."""

import base64
import bz2
import gzip
import hashlib
import logging
import lzma
import os
import re
import shutil
from collections.abc import Iterable
from pathlib import Path, PurePath
from subprocess import CalledProcessError, CompletedProcess
from textwrap import dedent
from typing import Any, ClassVar, IO, override
from unittest.mock import patch

import requests
import responses
from debian.deb822 import Deb822, Dsc, Packages, Release, Sources
from django_pglocks import advisory_lock
from responses.matchers import header_matcher

from debusine.artifacts.local_artifact import deb822dict_to_dict
from debusine.artifacts.models import (
    ArtifactCategory,
    CollectionCategory,
    DebianSourcePackage,
    WorkRequestResults,
)
from debusine.artifacts.playground import ArtifactPlayground
from debusine.db.locks import LockError, LockType
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    AssetUsage,
    Collection,
    Workspace,
)
from debusine.db.tests.utils import _calculate_hash_from_data
from debusine.server.collections.debian_suite import (
    make_pool_filename,
    make_source_prefix,
)
from debusine.server.tasks import APTMirror
from debusine.server.tasks.aptmirror import (
    InconsistentMirrorError,
    IndexFile,
    Plan,
    PlanAdd,
    PlanReplace,
)
from debusine.server.tasks.models import APTMirrorFilter
from debusine.server.tasks.tests.helpers import ServerTaskTestCase
from debusine.tasks import TaskConfigError
from debusine.test.test_utils import data_generator
from debusine.utils import calculate_hash, read_dsc


class APTMirrorTests(ServerTaskTestCase[APTMirror]):
    """Test the :py:class:`APTMirror` task."""

    SAMPLE_TASK_DATA = {
        "collection": "bookworm",
        "url": "https://deb.debian.org/debian",
        "suite": "bookworm",
        "components": ["main"],
        "architectures": None,
    }

    bookworm: ClassVar[Collection]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.bookworm = cls.create_suite_collection("bookworm")

    @classmethod
    def create_suite_collection(
        cls, name: str, workspace: Workspace | None = None
    ) -> Collection:
        """Create a `debian:suite` collection."""
        return cls.playground.create_collection(
            name=name, category=CollectionCategory.SUITE, workspace=workspace
        )

    def create_apt_mirror_task(
        self,
        collection_name: str,
        url: str = "https://deb.debian.org/debian",
        suite: str = "bookworm",
        architectures: list[str] | None = None,
        components: list[str] | None = None,
        signing_key: str | None = None,
        filters: list[APTMirrorFilter] | None = None,
    ) -> APTMirror:
        """Create an instance of the :py:class:`APTMirror` task."""
        task_data: dict[str, Any] = {
            "collection": collection_name,
            "url": url,
            "suite": suite,
            "architectures": architectures,
        }
        if not suite.endswith("/") and components is None:
            components = ["main"]
        if components is not None:
            task_data["components"] = components
        if signing_key is not None:
            task_data["signing_key"] = signing_key
        if filters is not None:
            task_data["filters"] = filters
        self.task_data = task_data
        return self.task

    def create_source_package_artifact(
        self, name: str, version: str, paths: list[str]
    ) -> Artifact:
        """Create a minimal `debian:source-package` artifact."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.SOURCE_PACKAGE,
            data={
                "name": name,
                "version": version,
                "type": "dpkg",
                "dsc_fields": {},
            },
            paths=paths,
            create_files=True,
            skip_add_files_in_store=True,
        )
        return artifact

    def create_binary_package_artifact(
        self,
        srcpkg_name: str,
        srcpkg_version: str,
        name: str,
        version: str,
        architecture: str,
        paths: list[str],
    ) -> Artifact:
        """Create a minimal `debian:binary-package` artifact."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.BINARY_PACKAGE,
            data={
                "srcpkg_name": srcpkg_name,
                "srcpkg_version": srcpkg_version,
                "deb_fields": {
                    "Package": name,
                    "Version": version,
                    "Architecture": architecture,
                },
                "deb_control_files": [],
            },
            paths=paths,
            create_files=True,
            skip_add_files_in_store=True,
        )
        return artifact

    def write_sample_source_package(
        self,
        temp_path: Path,
        name: str,
        version: str,
        *,
        component: str = "main",
        section: str = "devel",
        existing_artifact: Artifact | None = None,
    ) -> Sources:
        """Write a sample source package, returning its index entry."""
        if existing_artifact is None:
            tar_path = temp_path / f"{name}_{version}.tar.xz"
            tar_path.write_bytes(b"tar")

            dsc_path = temp_path / f"{name}_{version}.dsc"
            ArtifactPlayground.write_deb822_file(
                Dsc,
                dsc_path,
                [tar_path],
                format_version="3.0 (native)",
                source=name,
                version=version,
                section=section,
            )

            files = [
                (
                    hashlib.sha256(path.read_bytes()).hexdigest(),
                    len(path.read_bytes()),
                    path.name,
                )
                for path in (dsc_path, tar_path)
            ]
        else:
            files = [
                (fia.file.hash_digest.hex(), fia.file.size, fia.path)
                for fia in existing_artifact.fileinartifact_set.order_by("id")
            ]

        return Sources(
            {
                "Package": name,
                "Version": version,
                "Section": section,
                "Directory": (
                    f"pool/{component}/{make_source_prefix(name)}/{name}"
                ),
                "Checksums-Sha256": "".join(
                    f"{hash_digest} {size} {name}\n"
                    for hash_digest, size, name in files
                ),
            }
        )

    def write_sample_binary_package(
        self,
        temp_path: Path,
        name: str,
        version: str,
        architecture: str,
        *,
        srcpkg_name: str | None = None,
        srcpkg_version: str | None = None,
        component: str = "main",
        section: str = "devel",
        priority: str = "optional",
        existing_deb_path: Path | None = None,
    ) -> Packages:
        """Write a sample binary package, returning its index entry."""
        if existing_deb_path is None:
            deb_path = temp_path / f"{name}_{version}_{architecture}.deb"
            self.write_deb_file(
                deb_path,
                source_name=srcpkg_name or name,
                source_version=srcpkg_version or version,
            )
            if m := re.match(r"^\d+:(.*)", version):
                deb_path = deb_path.rename(
                    temp_path / f"{name}_{m.group(1)}_{architecture}.deb"
                )
        else:
            deb_path = existing_deb_path
        deb_contents = deb_path.read_bytes()
        binary = Packages(
            {
                "Package": name,
                "Version": version,
                "Architecture": architecture,
                "Section": section,
                "Priority": priority,
                "Filename": make_pool_filename(
                    srcpkg_name or name, component, deb_path.name
                ),
                "Size": str(len(deb_contents)),
                "SHA256": hashlib.sha256(deb_contents).hexdigest(),
            }
        )
        if srcpkg_name is not None and srcpkg_name != name:
            if srcpkg_version is not None and srcpkg_version != version:
                binary["Source"] = f"{srcpkg_name} ({srcpkg_version})"
            else:
                binary["Source"] = srcpkg_name
        return binary

    def write_sample_sources_file(
        self,
        temp_path: Path,
        sources_file: IO[str],
        source_names: list[str],
        *,
        component: str = "main",
    ) -> None:
        """Write a sample ``Sources`` file."""
        sources_file.write(
            "".join(
                self.write_sample_source_package(
                    temp_path, source_name, "1.0", component=component
                ).dump()
                + "\n"
                for source_name in source_names
            )
        )

    def write_sample_packages_file(
        self,
        temp_path: Path,
        packages_file: IO[str],
        binary_names: list[str],
        architecture: str = "all",
    ) -> None:
        """Write a sample ``Packages`` file."""
        packages_file.write(
            "".join(
                self.write_sample_binary_package(
                    temp_path, binary_name, "1.0", architecture
                ).dump()
                + "\n"
                for binary_name in binary_names
            )
        )

    def write_sample_release_file(
        self,
        indexes_path: Path,
        release_file: IO[str],
        index_paths: Iterable[Path],
    ) -> None:
        """Write a sample ``Release`` file."""
        release_file.write(
            Release(
                {
                    "SHA256": "".join(
                        f"{calculate_hash(path, 'sha256').hex()}"
                        f" {path.stat().st_size}"
                        f" {path.relative_to(indexes_path)}\n"
                        for path in index_paths
                    )
                }
            ).dump()
            + "\n"
        )

    def assert_artifact_files_match(
        self, artifact: Artifact, files: dict[str, bytes]
    ) -> None:
        """Assert that an artifact's files are as expected."""
        self.assertEqual(
            {
                file_in_artifact.path: file_in_artifact.file.hash_digest
                for file_in_artifact in artifact.fileinartifact_set.all()
            },
            {
                name: _calculate_hash_from_data(contents)
                for name, contents in files.items()
            },
        )

    def assert_artifact_matches(
        self,
        artifact: Artifact,
        category: ArtifactCategory,
        workspace: Workspace,
        data: dict[str, Any],
        files: dict[str, bytes],
    ) -> None:
        """Assert that an artifact is as expected."""
        self.assertEqual(artifact.category, category)
        self.assertEqual(artifact.workspace, workspace)
        self.assertEqual(artifact.data, data)
        self.assert_artifact_files_match(artifact, files)

    def test_compute_scheduler_tags(self) -> None:
        self.assertComputesSchedulerTags()

    def test_collection(self) -> None:
        """`collection` looks up the requested collection by name."""
        self.assertEqual(self.task.collection.collection, self.bookworm)

    def test_collection_nonexistent(self) -> None:
        """`collection` raises exception: collection doesn't exist."""
        default_workspace = self.playground.get_default_workspace()
        self.task_data["collection"] = "nonexistent"
        with self.assertRaisesRegex(
            TaskConfigError,
            (
                rf"Lookup for field 'collection' in {default_workspace} with"
                rf" category 'debian:suite' failed:"
                rf" 'nonexistent@debian:suite' does not exist or is hidden"
            ),
        ):
            self.task.collection

    def test_collection_wrong_category(self) -> None:
        """`collection` raises exception: wrong category."""
        default_workspace = self.playground.get_default_workspace()
        self.bookworm.category = CollectionCategory.ENVIRONMENTS
        self.bookworm.save()
        with self.assertRaisesRegex(
            TaskConfigError,
            (
                rf"Lookup for field 'collection' in {default_workspace} with"
                rf" category 'debian:suite' failed:"
                rf" 'bookworm@debian:suite' does not exist or is hidden"
            ),
        ):
            self.task.collection

    def test_collection_wrong_workspace(self) -> None:
        """`collection` raises exception: wrong category."""
        default_workspace = self.playground.get_default_workspace()
        workspace = self.playground.create_workspace(name="other")
        self.bookworm.workspace = workspace
        self.bookworm.save()
        with self.assertRaisesRegex(
            TaskConfigError,
            (
                rf"Lookup for field 'collection' in {default_workspace} with"
                rf" category 'debian:suite' failed:"
                rf" 'bookworm@debian:suite' does not exist or is hidden"
            ),
        ):
            self.task

    def test_authentication_data_none(self) -> None:
        self.assertIsNone(self.task.authentication_data)

    def test_authentication_data_no_asset_usage(self) -> None:
        self.task_data["authentication"] = "test"
        self.playground.create_apt_authentication_asset(name="test")
        with self.assertRaisesRegex(
            InconsistentMirrorError,
            f"{self.work_request.created_by} cannot use asset 'test'"
            " for APT authentication",
        ):
            self.task.authentication_data

    def test_authentication_data_no_permission(self) -> None:
        self.task_data["authentication"] = "test"
        authentication = self.playground.create_apt_authentication_asset(
            name="test"
        )
        self.playground.create_asset_usage(authentication)
        with self.assertRaisesRegex(
            InconsistentMirrorError,
            f"{self.work_request.created_by} cannot use asset 'test'"
            " for APT authentication",
        ):
            self.task.authentication_data

    def test_authentication_data_with_permission(self) -> None:
        self.task_data["authentication"] = "test"
        authentication = self.playground.create_apt_authentication_asset(
            name="test"
        )
        authentication_usage = self.playground.create_asset_usage(
            authentication
        )
        with self.playground.assign_role(
            authentication_usage,
            self.work_request.created_by,
            AssetUsage.Roles.APT_AUTHENTICATOR,
        ):
            self.assertEqual(
                self.task.authentication_data, authentication.data_model
            )

    def test_fetch_meta_indexes(self) -> None:
        """`fetch_meta_indexes` sets up apt and calls "apt-get update"."""
        temp_path = self.create_temporary_directory()

        with patch("subprocess.run") as mock_run:
            self.task.fetch_meta_indexes(temp_path)

        self.assertTrue((temp_path / "etc/apt/apt.conf.d").is_dir())
        self.assertFalse((temp_path / "etc/apt/auth.conf.d").is_dir())
        self.assertTrue((temp_path / "etc/apt/preferences.d").is_dir())
        self.assertTrue((temp_path / "etc/apt/sources.list.d").is_dir())
        self.assertTrue((temp_path / "var/lib/apt/lists/partial").is_dir())
        self.assertEqual(
            (apt_config := temp_path / "etc/apt/apt.conf").read_text(),
            dedent(
                f"""\
                Dir "{temp_path}";
                #clear Acquire::IndexTargets;
                """
            ),
        )
        self.assertEqual(
            (temp_path / "etc/apt/sources.list.d/mirror.sources").read_text(),
            dedent(
                """\
                Types: deb deb-src
                URIs: https://deb.debian.org/debian
                Suites: bookworm
                Components: main
                """
            ),
        )
        expected_env = os.environ.copy()
        expected_env["APT_CONFIG"] = str(apt_config)
        mock_run.assert_called_once_with(
            ["apt-get", "update"],
            cwd=None,
            env=expected_env,
            text=True,
            check=True,
            capture_output=True,
        )
        self.assertEqual(
            self.task.get_apt_release_file_name(temp_path, "Release"),
            "deb.debian.org_debian_dists_bookworm_Release",
        )

    def test_fetch_meta_indexes_with_authentication(self) -> None:
        self.task_data["authentication"] = "test"
        temp_path = self.create_temporary_directory()
        authentication = self.playground.create_apt_authentication_asset(
            name="test", username="test-username", password="test-password"
        )
        authentication_usage = self.playground.create_asset_usage(
            authentication
        )

        with (
            self.playground.assign_role(
                authentication_usage,
                self.work_request.created_by,
                AssetUsage.Roles.APT_AUTHENTICATOR,
            ),
            patch("subprocess.run"),
        ):
            self.task.fetch_meta_indexes(temp_path)

        self.assertEqual(
            (temp_path / "etc/apt/auth.conf.d/test.conf").read_text(),
            dedent(
                """\
                machine https://deb.debian.org/debian
                login test-username
                password test-password
                """
            ),
        )
        self.assertEqual(
            self.task.get_apt_release_file_name(temp_path, "Release"),
            "deb.debian.org_debian_dists_bookworm_Release",
        )

    def test_fetch_meta_indexes_flat(self) -> None:
        """`fetch_meta_indexes` handles flat repositories."""
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", url="https://deb.example.org/", suite="./"
        )

        with patch("subprocess.run"):
            task.fetch_meta_indexes(temp_path)

        self.assertEqual(
            (temp_path / "etc/apt/sources.list.d/mirror.sources").read_text(),
            dedent(
                """\
                Types: deb deb-src
                URIs: https://deb.example.org/
                Suites: ./
                """
            ),
        )
        self.assertEqual(
            task.get_apt_release_file_name(temp_path, "Release"),
            "deb.example.org_._Release",
        )

    def test_fetch_meta_indexes_with_signing_key(self) -> None:
        """`fetch_meta_indexes` handles a signing key."""
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm",
            signing_key=(
                "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
                "\n"
                "test\n"
                "-----END PGP PUBLIC KEY BLOCK-----\n"
            ),
        )

        with patch("subprocess.run"):
            task.fetch_meta_indexes(temp_path)

        self.assertEqual(
            (temp_path / "etc/apt/sources.list.d/mirror.sources").read_text(),
            dedent(
                """\
                Types: deb deb-src
                URIs: https://deb.debian.org/debian
                Suites: bookworm
                Components: main
                Signed-By:
                 -----BEGIN PGP PUBLIC KEY BLOCK-----
                 .
                 test
                 -----END PGP PUBLIC KEY BLOCK-----
                """
            ),
        )

    def test_fetch_meta_indexes_logs_errors(self) -> None:
        """`fetch_meta_indexes` logs stderr on failure."""
        temp_path = self.create_temporary_directory()
        with (
            patch(
                "subprocess.run",
                side_effect=CalledProcessError(
                    returncode=1, cmd=["apt-get", "update"], stderr="Boom\n"
                ),
            ),
            self.assertLogsContains(
                "Error output from apt-get update:\nBoom",
                logger="debusine.server.tasks.aptmirror",
                level=logging.ERROR,
            ),
            self.assertRaises(CalledProcessError),
        ):
            self.task.fetch_meta_indexes(temp_path)

    def test_get_release_path_prefers_InRelease(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", url="https://deb.example.org/"
        )
        inrelease_path = temp_path / "deb.example.org_dists_bookworm_InRelease"
        inrelease_path.touch()
        (temp_path / "deb.example.org_dists_bookworm_Release").touch()

        with (
            patch.object(task, "get_apt_lists_path", return_value=temp_path),
            patch.object(
                task, "get_apt_base_uri", return_value="http://deb.example.org/"
            ),
        ):
            self.assertEqual(task.get_release_path(temp_path), inrelease_path)

    def test_get_release_path_falls_back_to_Release(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", url="https://deb.example.org/"
        )
        release_path = temp_path / "deb.example.org_dists_bookworm_Release"
        release_path.touch()

        with (
            patch.object(task, "get_apt_lists_path", return_value=temp_path),
            patch.object(
                task, "get_apt_base_uri", return_value="http://deb.example.org/"
            ),
        ):
            self.assertEqual(task.get_release_path(temp_path), release_path)

    def test_get_release_path_flat(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", url="https://deb.example.org/", suite="./"
        )
        inrelease_path = temp_path / "deb.example.org_._InRelease"
        inrelease_path.touch()

        with (
            patch.object(task, "get_apt_lists_path", return_value=temp_path),
            patch.object(
                task, "get_apt_base_uri", return_value="http://deb.example.org/"
            ),
        ):
            self.assertEqual(task.get_release_path(temp_path), inrelease_path)

    def test_get_release_path_nonexistent(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", url="https://deb.example.org/"
        )

        with (
            patch.object(task, "get_apt_lists_path", return_value=temp_path),
            patch.object(
                task, "get_apt_base_uri", return_value="http://deb.example.org/"
            ),
            self.assertRaisesRegex(
                InconsistentMirrorError,
                "Cannot mirror a repository without a Release/InRelease file",
            ),
        ):
            task.get_release_path(temp_path)

    def test_fetch_archive_file(self) -> None:
        temp_path = self.create_temporary_directory()
        data = next(data_generator(16))

        with responses.RequestsMock() as rsps:
            rsps.add(responses.GET, f"{self.task.data.url}/file", body=data)

            self.assertTrue(
                self.task.fetch_archive_file(
                    temp_path,
                    "file",
                    expected_size=len(data),
                    expected_sha256=hashlib.sha256(data).hexdigest(),
                )
            )

        self.assertEqual(data, (temp_path / "file").read_bytes())

    @responses.activate
    def test_fetch_archive_file_escapes_root(self) -> None:
        temp_path = self.create_temporary_directory()
        with self.assertRaisesRegex(
            InconsistentMirrorError,
            re.escape(f"'../file' escapes {temp_path}"),
        ):
            self.task.fetch_archive_file(
                temp_path, "../file", expected_size=0, expected_sha256=""
            )

    def test_fetch_archive_file_with_authentication(self) -> None:
        self.task_data["authentication"] = "test"
        temp_path = self.create_temporary_directory()
        authentication = self.playground.create_apt_authentication_asset(
            name="test", username="test-username", password="test-password"
        )
        authentication_usage = self.playground.create_asset_usage(
            authentication
        )
        data = next(data_generator(16))

        with (
            self.playground.assign_role(
                authentication_usage,
                self.work_request.created_by,
                AssetUsage.Roles.APT_AUTHENTICATOR,
            ),
            responses.RequestsMock() as rsps,
        ):
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/file",
                match=[
                    header_matcher(
                        {
                            "Authorization": "Basic "
                            + base64.b64encode(
                                b"test-username:test-password"
                            ).decode()
                        }
                    )
                ],
                body=data,
            )

            self.assertTrue(
                self.task.fetch_archive_file(
                    temp_path,
                    "file",
                    expected_size=len(data),
                    expected_sha256=hashlib.sha256(data).hexdigest(),
                )
            )

        self.assertEqual(data, (temp_path / "file").read_bytes())

    def test_fetch_archive_file_not_found_allowed(self) -> None:
        temp_path = self.create_temporary_directory()
        with (
            responses.RequestsMock() as rsps,
            self.assertLogsContains(
                f"{self.task.data.url}/file not found; skipping",
                logger="debusine.server.tasks.aptmirror",
                level=logging.INFO,
            ),
        ):
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/file",
                status=requests.codes.not_found,
            )

            self.assertFalse(
                self.task.fetch_archive_file(
                    temp_path,
                    "file",
                    expected_size=0,
                    expected_sha256="",
                    must_exist=False,
                )
            )

    def test_fetch_archive_file_not_found_error(self) -> None:
        temp_path = self.create_temporary_directory()
        with (
            responses.RequestsMock() as rsps,
            self.assertLogsContains(
                f"Failed to download {self.task.data.url}/file",
                logger="debusine.server.tasks.aptmirror",
                level=logging.ERROR,
            ),
            self.assertRaises(requests.HTTPError),
        ):
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/file",
                status=requests.codes.not_found,
            )

            self.task.fetch_archive_file(
                temp_path,
                "file",
                expected_size=0,
                expected_sha256="",
                must_exist=True,
            )

    def test_fetch_archive_file_unauthorized_allowed(self) -> None:
        temp_path = self.create_temporary_directory()
        for status in (requests.codes.unauthorized, requests.codes.forbidden):
            with (
                self.subTest(status=status),
                responses.RequestsMock() as rsps,
                self.assertLogsContains(
                    f"{self.task.data.url}/file not authorized; skipping",
                    logger="debusine.server.tasks.aptmirror",
                    level=logging.INFO,
                ),
            ):
                rsps.add(
                    responses.GET, f"{self.task.data.url}/file", status=status
                )
                self.assertFalse(
                    self.task.fetch_archive_file(
                        temp_path,
                        "file",
                        expected_size=0,
                        expected_sha256="",
                        ignore_unauthorized=True,
                    )
                )

    def test_fetch_archive_file_unauthorized_error(self) -> None:
        temp_path = self.create_temporary_directory()
        for status in (requests.codes.unauthorized, requests.codes.forbidden):
            with (
                self.subTest(status=status),
                responses.RequestsMock() as rsps,
                self.assertLogsContains(
                    f"Failed to download {self.task.data.url}/file",
                    logger="debusine.server.tasks.aptmirror",
                    level=logging.ERROR,
                ),
                self.assertRaises(requests.HTTPError),
            ):
                rsps.add(
                    responses.GET, f"{self.task.data.url}/file", status=status
                )

                self.task.fetch_archive_file(
                    temp_path,
                    "file",
                    expected_size=0,
                    expected_sha256="",
                    ignore_unauthorized=False,
                )

    def test_fetch_archive_file_wrong_size(self) -> None:
        temp_path = self.create_temporary_directory()
        data = next(data_generator(16))

        with (
            responses.RequestsMock() as rsps,
            self.assertRaisesRegex(
                InconsistentMirrorError,
                "Expected 'file' to have size 32 bytes; got 16 bytes",
            ),
        ):
            rsps.add(responses.GET, f"{self.task.data.url}/file", body=data)

            self.task.fetch_archive_file(
                temp_path,
                "file",
                expected_size=32,
                expected_sha256=hashlib.sha256(data).hexdigest(),
            )

        self.assertFalse((temp_path / "file").exists())

    def test_fetch_archive_file_wrong_sha256(self) -> None:
        temp_path = self.create_temporary_directory()
        data = next(data_generator(16))
        sha256 = hashlib.sha256(data).hexdigest()

        with (
            responses.RequestsMock() as rsps,
            self.assertRaisesRegex(
                InconsistentMirrorError,
                f"Expected 'file' to have SHA256 'wrong-sha256';"
                f" got '{sha256}'",
            ),
        ):
            rsps.add(responses.GET, f"{self.task.data.url}/file", body=data)

            self.task.fetch_archive_file(
                temp_path,
                "file",
                expected_size=16,
                expected_sha256="wrong-sha256",
            )

        self.assertFalse((temp_path / "file").exists())

    def test_fetch_indexes(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task("bookworm", components=["contrib"])

        origin_path = temp_path / "origin"

        origin_sources_path = origin_path / "contrib/source/Sources"
        origin_sources_path.parent.mkdir(parents=True)
        with open(origin_sources_path, "w") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])
        origin_sources_gz_path = origin_sources_path.with_suffix(".gz")
        with gzip.open(origin_sources_gz_path, "wt") as sources_gz:
            sources_gz.write(origin_sources_path.read_text())
        origin_sources_xz_path = origin_sources_path.with_suffix(".xz")
        with lzma.open(
            origin_sources_xz_path, "wt", format=lzma.FORMAT_XZ
        ) as sources_xz:
            sources_xz.write(origin_sources_path.read_text())

        origin_packages_path = origin_path / "contrib/binary-amd64/Packages"
        origin_packages_path.parent.mkdir(parents=True)
        with open(origin_packages_path, "w") as packages:
            self.write_sample_packages_file(
                temp_path, packages, ["hello"], architecture="amd64"
            )
        origin_packages_gz_path = origin_packages_path.with_suffix(".gz")
        with gzip.open(origin_packages_gz_path, "wt") as packages_gz:
            packages_gz.write(origin_packages_path.read_text())
        origin_packages_xz_path = origin_packages_path.with_suffix(".xz")
        with lzma.open(
            origin_packages_xz_path, "wt", format=lzma.FORMAT_XZ
        ) as packages_xz:
            packages_xz.write(origin_packages_path.read_text())

        origin_contents_amd64_path = origin_path / "contrib/Contents-amd64"
        origin_contents_amd64_path.write_text("Contents-amd64\n")
        origin_contents_udeb_amd64_path = (
            origin_path / "contrib/Contents-udeb-amd64"
        )
        origin_contents_udeb_amd64_path.write_text("Contents-udeb-amd64\n")

        origin_translation_en_path = origin_path / "contrib/i18n/Translation-en"
        origin_translation_en_path.parent.mkdir(parents=True)
        origin_translation_en_path.write_text("Translation-en\n")
        origin_translation_en_bz2_path = origin_translation_en_path.with_suffix(
            ".bz2"
        )
        with bz2.open(
            origin_translation_en_bz2_path, "wt"
        ) as translation_en_bz2:
            translation_en_bz2.write(origin_translation_en_path.read_text())

        (unused_path := origin_path / "Unused").touch()

        release_path = temp_path / "Release"
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                origin_path,
                release,
                (
                    origin_sources_path,
                    origin_sources_gz_path,
                    origin_sources_xz_path,
                    origin_packages_path,
                    origin_packages_gz_path,
                    origin_packages_xz_path,
                    origin_contents_amd64_path,
                    origin_contents_udeb_amd64_path,
                    origin_translation_en_path,
                    origin_translation_en_bz2_path,
                    unused_path,
                ),
            )

        with (
            responses.RequestsMock() as rsps,
            patch.object(task, "get_release_path", return_value=release_path),
        ):
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/contrib/source/Sources.xz",
                body=origin_sources_xz_path.read_bytes(),
            )
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/contrib/binary-amd64/"
                f"Packages.xz",
                body=origin_packages_xz_path.read_bytes(),
            )
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/contrib/Contents-amd64",
                body=origin_contents_amd64_path.read_bytes(),
            )
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/contrib/Contents-udeb-amd64",
                body=origin_contents_udeb_amd64_path.read_bytes(),
            )
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/contrib/i18n/"
                "Translation-en.bz2",
                body=origin_translation_en_bz2_path.read_bytes(),
            )

            task.fetch_indexes(temp_path)

            indexes_path = temp_path / "indexes"
            self.assertEqual(
                task.get_fetched_index_files(temp_path),
                {
                    rel_path: (
                        (indexes_path / "dists/bookworm" / rel_path),
                        IndexFile(
                            component="contrib",
                            architecture=architecture,
                            size=len(origin.read_bytes()),
                            sha256=calculate_hash(origin, "sha256").hex(),
                            must_exist=must_exist,
                        ),
                    )
                    for rel_path, architecture, origin, must_exist in (
                        (
                            "contrib/source/Sources.xz",
                            None,
                            origin_sources_xz_path,
                            True,
                        ),
                        (
                            "contrib/binary-amd64/Packages.xz",
                            "amd64",
                            origin_packages_xz_path,
                            True,
                        ),
                        (
                            "contrib/Contents-amd64",
                            "amd64",
                            origin_contents_amd64_path,
                            False,
                        ),
                        (
                            "contrib/Contents-udeb-amd64",
                            "amd64",
                            origin_contents_udeb_amd64_path,
                            False,
                        ),
                        (
                            "contrib/i18n/Translation-en.bz2",
                            None,
                            origin_translation_en_bz2_path,
                            True,
                        ),
                    )
                },
            )

    def test_fetch_indexes_flat(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", url="https://deb.example.org/", suite="./"
        )

        (origin_path := temp_path / "origin").mkdir()
        origin_sources_path = origin_path / "Sources"
        with open(origin_sources_path, "w") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])
        origin_packages_path = origin_path / "Packages"
        with open(origin_packages_path, "w") as packages:
            self.write_sample_packages_file(
                temp_path, packages, ["hello"], architecture="amd64"
            )

        release_path = temp_path / "Release"
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                origin_path,
                release,
                (origin_sources_path, origin_packages_path),
            )

        with (
            responses.RequestsMock() as rsps,
            patch.object(task, "get_release_path", return_value=release_path),
        ):
            rsps.add(
                responses.GET,
                f"{task.data.url}Sources",
                body=origin_sources_path.read_bytes(),
            )
            rsps.add(
                responses.GET,
                f"{task.data.url}Packages",
                body=origin_packages_path.read_bytes(),
            )

            task.fetch_indexes(temp_path)

            indexes_path = temp_path / "indexes"
            self.assertEqual(
                task.get_fetched_index_files(temp_path),
                {
                    rel_path: (
                        indexes_path / rel_path,
                        IndexFile(
                            component="main",
                            size=len(origin.read_bytes()),
                            sha256=calculate_hash(origin, "sha256").hex(),
                            must_exist=False,
                        ),
                    )
                    for rel_path, origin in (
                        ("Sources", origin_sources_path),
                        ("Packages", origin_packages_path),
                    )
                },
            )

    def test_fetch_indexes_honours_components(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task("bookworm", components=["contrib"])

        origin_path = temp_path / "origin"
        origin_packages_paths: dict[str, Path] = {}
        for component in ("main", "contrib"):
            packages_path = origin_path / f"{component}/binary-amd64/Packages"
            packages_path.parent.mkdir(parents=True)
            with open(packages_path, "w") as packages:
                self.write_sample_packages_file(
                    temp_path, packages, ["hello"], architecture="amd64"
                )
            origin_packages_paths[component] = packages_path

        release_path = temp_path / "Release"
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                origin_path, release, origin_packages_paths.values()
            )

        with (
            responses.RequestsMock() as rsps,
            patch.object(task, "get_release_path", return_value=release_path),
        ):
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/contrib/binary-amd64/Packages",
                body=origin_packages_paths["contrib"].read_bytes(),
            )

            with self.assertLogsContains(
                "Skipping main/binary-amd64/Packages"
                " (component not in ['contrib'])",
                logger="debusine.server.tasks.aptmirror",
                level=logging.INFO,
            ):
                task.fetch_indexes(temp_path)

            indexes_path = temp_path / "indexes"
            self.assertEqual(
                task.get_fetched_index_files(temp_path),
                {
                    "contrib/binary-amd64/Packages": (
                        (
                            indexes_path
                            / "dists/bookworm/contrib/binary-amd64/Packages"
                        ),
                        IndexFile(
                            component="contrib",
                            architecture="amd64",
                            size=len(
                                origin_packages_paths["contrib"].read_bytes()
                            ),
                            sha256=calculate_hash(
                                origin_packages_paths["contrib"], "sha256"
                            ).hex(),
                            must_exist=False,
                        ),
                    ),
                },
            )

    def test_fetch_indexes_honours_architectures(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task("bookworm", architectures=["s390x"])

        origin_path = temp_path / "origin"
        origin_packages_paths: dict[str, Path] = {}
        origin_contents_paths: dict[str, Path] = {}
        for architecture in ("amd64", "s390x"):
            packages_path = origin_path / f"main/binary-{architecture}/Packages"
            packages_path.parent.mkdir(parents=True)
            with open(packages_path, "w") as packages:
                self.write_sample_packages_file(
                    temp_path, packages, ["hello"], architecture=architecture
                )
            origin_packages_paths[architecture] = packages_path

            contents_path = origin_path / f"main/Contents-{architecture}"
            contents_path.write_text(f"Contents-{architecture}\n")
            origin_contents_paths[architecture] = contents_path

        release_path = temp_path / "Release"
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                origin_path,
                release,
                tuple(origin_packages_paths.values())
                + tuple(origin_contents_paths.values()),
            )

        with (
            responses.RequestsMock() as rsps,
            patch.object(task, "get_release_path", return_value=release_path),
        ):
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/main/binary-s390x/Packages",
                body=origin_packages_paths["s390x"].read_bytes(),
            )
            rsps.add(
                responses.GET,
                f"{task.data.url}/dists/bookworm/main/Contents-s390x",
                body=origin_contents_paths["s390x"].read_bytes(),
            )

            with self.assertLogsContains(
                "Skipping main/binary-amd64/Packages"
                " (architecture not in ['all', 's390x'])",
                logger="debusine.server.tasks.aptmirror",
                level=logging.INFO,
            ):
                task.fetch_indexes(temp_path)

            indexes_path = temp_path / "indexes"
            self.assertEqual(
                task.get_fetched_index_files(temp_path),
                {
                    rel_path: (
                        indexes_path / "dists/bookworm" / rel_path,
                        IndexFile(
                            component="main",
                            architecture="s390x",
                            size=len(origin.read_bytes()),
                            sha256=calculate_hash(origin, "sha256").hex(),
                            must_exist=False,
                        ),
                    )
                    for rel_path, origin in (
                        (
                            "main/binary-s390x/Packages",
                            origin_packages_paths["s390x"],
                        ),
                        ("main/Contents-s390x", origin_contents_paths["s390x"]),
                    )
                },
            )

    def test_fetch_indexes_no_alternatives_found(self) -> None:
        temp_path = self.create_temporary_directory()
        origin_path = temp_path / "origin"
        origin_sources_path = origin_path / "main/source/Sources"
        origin_sources_path.parent.mkdir(parents=True)
        with open(origin_sources_path, "w") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])

        release_path = temp_path / "Release"
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                origin_path, release, (origin_sources_path,)
            )

        with (
            responses.RequestsMock() as rsps,
            patch.object(
                self.task, "get_release_path", return_value=release_path
            ),
            self.assertRaisesRegex(
                InconsistentMirrorError,
                "No alternatives found for 'main/source/Sources'",
            ),
        ):
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/dists/bookworm/main/source/Sources",
                status=requests.codes.not_found,
            )
            self.task.fetch_indexes(temp_path)

    def test_plan_sources_add(self) -> None:
        """`plan_sources` plans to add sources to the collection."""
        temp_path = self.create_temporary_directory()
        indexes_path = temp_path / "indexes"
        sources_path = indexes_path / "dists/bookworm/main/source/Sources"
        sources_path.parent.mkdir(parents=True)
        with sources_path.open(mode="w") as sources:
            sources.writelines(
                self.write_sample_source_package(
                    temp_path, name, version
                ).dump()
                + "\n"
                for name, version in (("pkg1", "1.0"), ("pkg2", "2.0"))
            )
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (sources_path,)
            )

        with patch.object(
            self.task, "get_release_path", return_value=release_path
        ):
            plan = self.task.plan_sources(temp_path)

        self.assertEqual(
            [(a.name, a.contents["Package"], a.component) for a in plan.add],
            [("pkg1_1.0", "pkg1", "main"), ("pkg2_2.0", "pkg2", "main")],
        )
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [])

    def test_plan_sources_replace(self) -> None:
        """`plan_sources` plans to replace sources in the collection."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        source_package_artifacts = [
            self.create_source_package_artifact(
                name=name,
                version=version,
                paths=[f"{name}_{version}.dsc", f"{name}_{version}.tar.xz"],
            )
            for name, version in (("pkg1", "1.0"), ("pkg1", "1.1"))
        ]
        items = [
            collection.manager.add_artifact(
                source_package_artifact,
                user=self.work_request.created_by,
                variables={"component": "main", "section": "devel"},
            )
            for source_package_artifact in source_package_artifacts
        ]

        indexes_path = temp_path / "indexes"
        sources_path = indexes_path / "dists/bookworm/main/source/Sources"
        sources_path.parent.mkdir(parents=True)
        with sources_path.open(mode="w") as sources:
            sources.write(
                self.write_sample_source_package(
                    temp_path, "pkg1", "1.0"
                ).dump()
                + "\n"
            )
            dsc_1_1_file = (
                source_package_artifacts[1]
                .fileinartifact_set.get(path="pkg1_1.1.dsc")
                .file
            )
            dsc_1_1_hash = dsc_1_1_file.hash_digest.hex()
            dsc_1_1_size = dsc_1_1_file.size
            tar_1_1_file = (
                source_package_artifacts[1]
                .fileinartifact_set.get(path="pkg1_1.1.tar.xz")
                .file
            )
            tar_1_1_hash = tar_1_1_file.hash_digest.hex()
            tar_1_1_size = tar_1_1_file.size
            sources.write(
                Sources(
                    {
                        "Package": "pkg1",
                        "Version": "1.1",
                        "Section": "devel",
                        "Checksums-Sha256": dedent(
                            f"""\
                            {dsc_1_1_hash} {dsc_1_1_size} pkg1_1.1.dsc
                            {tar_1_1_hash} {tar_1_1_size} pkg1_1.1.tar.xz
                            """
                        ),
                    }
                ).dump()
                + "\n"
            )
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (sources_path,)
            )

        with patch.object(
            self.task, "get_release_path", return_value=release_path
        ):
            plan = self.task.plan_sources(temp_path)

        self.assertEqual(plan.add, [])
        self.assertEqual(
            [
                (r.name, r.contents["Package"], r.component, r.item)
                for r in plan.replace
            ],
            [("pkg1_1.0", "pkg1", "main", items[0])],
        )
        self.assertEqual(plan.remove, [])

    def test_plan_sources_remove(self) -> None:
        """`plan_sources` plans to remove sources from the collection."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        source_package_artifacts = [
            self.create_source_package_artifact(
                name=name,
                version=version,
                paths=[f"{name}_{version}.dsc", f"{name}_{version}.tar.xz"],
            )
            for name, version in (("pkg1", "1.0"), ("pkg2", "2.0"))
        ]
        items = [
            collection.manager.add_artifact(
                source_package_artifact,
                user=self.work_request.created_by,
                variables={"component": "main", "section": "devel"},
            )
            for source_package_artifact in source_package_artifacts
        ]

        indexes_path = temp_path / "indexes"
        sources_path = indexes_path / "dists/bookworm/main/source/Sources"
        sources_path.parent.mkdir(parents=True)
        sources_path.touch()
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (sources_path,)
            )

        with patch.object(
            self.task, "get_release_path", return_value=release_path
        ):
            plan = self.task.plan_sources(temp_path)

        self.assertEqual(plan.add, [])
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, items)

    def test_plan_sources_inconsistent(self) -> None:
        """`plan_sources` fails with the same source in multiple components."""
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", components=["main", "contrib"]
        )

        indexes_path = temp_path / "indexes"
        sources_paths: list[Path] = []
        for component in ("main", "contrib"):
            sources_path = (
                indexes_path / f"dists/bookworm/{component}/source/Sources"
            )
            sources_path.parent.mkdir(parents=True)
            with sources_path.open(mode="w") as sources:
                sources.write(
                    self.write_sample_source_package(
                        temp_path, "pkg1", "1.0", component=component
                    ).dump()
                    + "\n"
                )
            sources_paths.append(sources_path)
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, sources_paths
            )

        with patch.object(task, "get_release_path", return_value=release_path):
            self.assertRaisesRegex(
                InconsistentMirrorError,
                r"pkg1_1\.0 found in multiple components: main and contrib",
                task.plan_sources,
                temp_path,
            )

    def test_plan_sources_filters(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm",
            filters=[
                APTMirrorFilter(section="libs", source_name=r".*2"),
                APTMirrorFilter(section="misc"),
                APTMirrorFilter(section="php", ignore_unauthorized=True),
                APTMirrorFilter(binary_name=r"^python.*$"),
            ],
        )

        indexes_path = temp_path / "indexes"
        sources_path = indexes_path / "dists/bookworm/main/source/Sources"
        sources_path.parent.mkdir(parents=True)
        with sources_path.open(mode="w") as sources:
            sources.writelines(
                self.write_sample_source_package(
                    temp_path, name, "1.0", section=section
                ).dump()
                + "\n"
                for name, section in (
                    ("pkg1", "devel"),
                    ("pkg2", "libs"),
                    ("pkg3", "libs"),
                    ("pkg4", "misc"),
                    ("pkg5", "misc"),
                    ("pkg6", "php"),
                )
            )
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (sources_path,)
            )

        with patch.object(task, "get_release_path", return_value=release_path):
            plan = task.plan_sources(temp_path)

        self.assertEqual(
            [(a.contents["Package"], a.ignore_unauthorized) for a in plan.add],
            [("pkg2", False), ("pkg4", False), ("pkg5", False), ("pkg6", True)],
        )
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [])

    def test_add_source(self) -> None:
        """`add_source` downloads and adds a source package."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        sources_entry = self.write_sample_source_package(
            temp_path, "hello", "1.0"
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            for name in ("hello_1.0.dsc", "hello_1.0.tar.xz"):
                rsps.add(
                    responses.GET,
                    f"{self.task.data.url}/pool/main/h/hello/{name}",
                    body=(temp_path / name).read_bytes(),
                )

            self.task.add_source(
                temp_path,
                PlanAdd[Sources](
                    name="hello_1.0", contents=sources_entry, component="main"
                ),
            )

        source_item = collection.manager.lookup("source-version:hello_1.0")
        assert source_item is not None
        assert source_item.artifact is not None
        self.assert_artifact_matches(
            source_item.artifact,
            ArtifactCategory.SOURCE_PACKAGE,
            self.task.workspace,
            {
                "name": "hello",
                "version": "1.0",
                "type": "dpkg",
                "dsc_fields": deb822dict_to_dict(
                    read_dsc(temp_path / "hello_1.0.dsc")
                ),
            },
            {
                name: (temp_path / name).read_bytes()
                for name in ("hello_1.0.dsc", "hello_1.0.tar.xz")
            },
        )
        item = collection.child_items.get()
        self.assertEqual(item.created_by_user, self.work_request.created_by)
        self.assertEqual(item.data["component"], "main")
        self.assertEqual(item.data["section"], "devel")

    def test_add_source_already_in_parent_archive(self) -> None:
        """`add_source` reuses matching artifacts from the parent archive."""
        temp_path = self.create_temporary_directory()
        user = self.playground.get_default_user()
        archive = self.playground.create_singleton_collection(
            CollectionCategory.ARCHIVE
        )
        archive.data["may_reuse_versions"] = True
        archive.save()
        bookworm = self.bookworm
        trixie = self.create_suite_collection("trixie")
        archive.manager.add_collection(bookworm, user=user)
        archive.manager.add_collection(trixie, user=user)
        task = self.create_apt_mirror_task("trixie")
        self.playground.create_group_role(
            task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[task.work_request.created_by],
        )

        existing = [
            self.playground.create_artifact_from_local(
                self.playground.create_source_package(
                    temp_path, name="hello", version=version
                ),
                create_files=True,
            )
            for version in ("1.0", "1.1")
        ]
        for suite, artifact in ((bookworm, existing[0]), (trixie, existing[1])):
            suite.manager.add_artifact(
                artifact,
                user=user,
                variables={"component": "main", "section": "devel"},
            )

        sources_entries = [
            self.write_sample_source_package(
                temp_path, "hello", version, existing_artifact=existing_artifact
            )
            for version, existing_artifact in (
                ("1.0", existing[0]),
                ("1.1", None),
                ("1.2", None),
            )
        ]

        task.work_request.set_current()

        # A source package that is already in the archive is not downloaded.
        with responses.RequestsMock():
            task.add_source(
                temp_path,
                PlanAdd[Sources](
                    name="hello_1.0",
                    contents=sources_entries[0],
                    component="main",
                ),
            )

        # A source package that has a matching version in the archive but
        # with wrong checksums is downloaded.
        with responses.RequestsMock() as rsps:
            for name in ("hello_1.1.dsc", "hello_1.1.tar.xz"):
                rsps.add(
                    responses.GET,
                    f"{task.data.url}/pool/main/h/hello/{name}",
                    body=(temp_path / name).read_bytes(),
                )

            task.add_source(
                temp_path,
                PlanAdd[Sources](
                    name="hello_1.1",
                    contents=sources_entries[1],
                    component="main",
                ),
            )

        # A source package that is not already in the archive is downloaded.
        with responses.RequestsMock() as rsps:
            for name in ("hello_1.2.dsc", "hello_1.2.tar.xz"):
                rsps.add(
                    responses.GET,
                    f"{task.data.url}/pool/main/h/hello/{name}",
                    body=(temp_path / name).read_bytes(),
                )

            task.add_source(
                temp_path,
                PlanAdd[Sources](
                    name="hello_1.2",
                    contents=sources_entries[2],
                    component="main",
                ),
            )

        copied_item = trixie.manager.lookup("source-version:hello_1.0")
        assert copied_item is not None
        self.assertEqual(copied_item.artifact, existing[0])
        self.assertEqual(
            copied_item.created_by_user, task.work_request.created_by
        )
        self.assertEqual(copied_item.data["component"], "main")
        self.assertEqual(copied_item.data["section"], "devel")

        for version in ("1.1", "1.2"):
            downloaded_item = trixie.manager.lookup(
                f"source-version:hello_{version}"
            )
            assert downloaded_item is not None
            self.assertNotEqual(downloaded_item.artifact, existing[0])
            self.assertNotEqual(downloaded_item.artifact, existing[1])
            self.assertEqual(
                downloaded_item.created_by_user, task.work_request.created_by
            )
            self.assertEqual(downloaded_item.data["component"], "main")
            self.assertEqual(downloaded_item.data["section"], "devel")

    def test_add_source_can_rewind(self) -> None:
        """There may already be a newer version of this source package."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        collection.manager.add_artifact(
            self.playground.create_artifact(
                category=ArtifactCategory.SOURCE_PACKAGE,
                data=DebianSourcePackage(
                    name="hello", version="1.1", type="dpkg", dsc_fields={}
                ),
            )[0],
            user=self.playground.get_default_user(),
            variables={"component": "main", "section": "devel"},
        )
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        sources_entry = self.write_sample_source_package(
            temp_path, "hello", "1.0"
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            for name in ("hello_1.0.dsc", "hello_1.0.tar.xz"):
                rsps.add(
                    responses.GET,
                    f"{self.task.data.url}/pool/main/h/hello/{name}",
                    body=(temp_path / name).read_bytes(),
                )

            self.task.add_source(
                temp_path,
                PlanAdd[Sources](
                    name="hello_1.0", contents=sources_entry, component="main"
                ),
            )

        source_item = collection.manager.lookup("source-version:hello_1.0")
        assert source_item is not None
        assert source_item.artifact is not None
        self.assert_artifact_matches(
            source_item.artifact,
            ArtifactCategory.SOURCE_PACKAGE,
            self.task.workspace,
            {
                "name": "hello",
                "version": "1.0",
                "type": "dpkg",
                "dsc_fields": deb822dict_to_dict(
                    read_dsc(temp_path / "hello_1.0.dsc")
                ),
            },
            {
                name: (temp_path / name).read_bytes()
                for name in ("hello_1.0.dsc", "hello_1.0.tar.xz")
            },
        )
        self.assertEqual(
            source_item.created_by_user, self.work_request.created_by
        )
        self.assertEqual(source_item.data["component"], "main")
        self.assertEqual(source_item.data["section"], "devel")

    def test_add_source_ignore_unauthorized(self) -> None:
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        hello_entry = self.write_sample_source_package(
            temp_path, "hello", "1.0"
        )
        skip_entry = self.write_sample_source_package(temp_path, "skip", "1.0")
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            for name in ("hello_1.0.dsc", "hello_1.0.tar.xz"):
                rsps.add(
                    responses.GET,
                    f"{self.task.data.url}/pool/main/h/hello/{name}",
                    body=(temp_path / name).read_bytes(),
                )
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/s/skip/skip_1.0.dsc",
                status=requests.codes.unauthorized,
            )

            for name, contents in (
                ("hello_1.0", hello_entry),
                ("skip_1.0", skip_entry),
            ):
                self.task.add_source(
                    temp_path,
                    PlanAdd[Sources](
                        name=name,
                        contents=contents,
                        component="main",
                        ignore_unauthorized=True,
                    ),
                )

        self.assertIsNotNone(
            collection.manager.lookup("source-version:hello_1.0")
        )
        self.assertIsNone(collection.manager.lookup("source-version:skip_1.0"))

    def test_update_sources(self) -> None:
        """`update_sources` executes a plan to update sources."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        collection.data["may_reuse_versions"] = True
        collection.save()
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        source_package_artifacts = [
            self.create_source_package_artifact(
                name=name,
                version=version,
                paths=[f"{name}_{version}.dsc", f"{name}_{version}.tar.xz"],
            )
            for name, version in (("to-replace", "1.0"), ("to-remove", "1.0"))
        ]
        items = [
            collection.manager.add_artifact(
                source_package_artifact,
                user=self.work_request.created_by,
                variables={"component": "main", "section": "devel"},
            )
            for source_package_artifact in source_package_artifacts
        ]

        to_add_entry = self.write_sample_source_package(
            temp_path, "to-add", "1.0"
        )
        to_replace_entry = self.write_sample_source_package(
            temp_path, "to-replace", "1.0"
        )
        plan = Plan[Sources](
            add=[
                PlanAdd[Sources](
                    name="to-add_1.0", contents=to_add_entry, component="main"
                )
            ],
            replace=[
                PlanReplace[Sources](
                    name="to-replace_1.0",
                    contents=to_replace_entry,
                    component="main",
                    item=items[0],
                )
            ],
            remove=[items[1]],
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            for name in (
                "to-add_1.0.dsc",
                "to-add_1.0.tar.xz",
                "to-replace_1.0.dsc",
                "to-replace_1.0.tar.xz",
            ):
                src_name = name.split("_", 1)[0]
                rsps.add(
                    responses.GET,
                    f"{self.task.data.url}/pool/main/"
                    f"{make_source_prefix(src_name)}/{src_name}/{name}",
                    body=(temp_path / name).read_bytes(),
                )

            self.task.update_sources(temp_path, plan)

        active_items = {
            item.name: item
            for item in collection.child_items.all()
            if item.removed_at is None
        }
        self.assertEqual(active_items.keys(), {"to-add_1.0", "to-replace_1.0"})
        assert active_items["to-add_1.0"].artifact is not None
        self.assert_artifact_files_match(
            active_items["to-add_1.0"].artifact,
            {
                name: (temp_path / name).read_bytes()
                for name in ("to-add_1.0.dsc", "to-add_1.0.tar.xz")
            },
        )
        assert active_items["to-replace_1.0"].artifact is not None
        self.assert_artifact_files_match(
            active_items["to-replace_1.0"].artifact,
            {
                name: (temp_path / name).read_bytes()
                for name in ("to-replace_1.0.dsc", "to-replace_1.0.tar.xz")
            },
        )

    def test_plan_binaries_add(self) -> None:
        """`plan_binaries` plans to add binaries to the collection."""
        temp_path = self.create_temporary_directory()

        indexes_path = temp_path / "indexes"
        packages_path = (
            indexes_path / "dists/bookworm/main/binary-amd64/Packages"
        )
        packages_path.parent.mkdir(parents=True)
        with packages_path.open(mode="w") as packages:
            packages.writelines(
                self.write_sample_binary_package(
                    temp_path, name, version, "amd64"
                ).dump()
                + "\n"
                for name, version in (("pkg1", "1.0"), ("pkg2", "2.0"))
            )
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (packages_path,)
            )

        with patch.object(
            self.task, "get_release_path", return_value=release_path
        ):
            plan = self.task.plan_binaries(temp_path)

        self.assertEqual(
            [(a.name, a.contents["Package"], a.component) for a in plan.add],
            [
                ("pkg1_1.0_amd64", "pkg1", "main"),
                ("pkg2_2.0_amd64", "pkg2", "main"),
            ],
        )
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [])

    def test_plan_binaries_multiple_same_component(self) -> None:
        """
        `plan_binaries` accepts multiple copies of arch-all binaries.

        `Architecture: all` binaries normally appear in `Packages` files for
        multiple architectures in the same component.
        """
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", architectures=["amd64", "s390x"]
        )

        indexes_path = temp_path / "indexes"
        packages_paths: list[Path] = []
        shared_deb_path = temp_path / "pkg1_1.0_all.deb"
        self.write_deb_file(shared_deb_path)
        for architecture in ("amd64", "s390x"):
            packages_path = (
                indexes_path
                / f"dists/bookworm/main/binary-{architecture}/Packages"
            )
            packages_path.parent.mkdir(parents=True)
            with packages_path.open(mode="w") as packages:
                packages.write(
                    self.write_sample_binary_package(
                        temp_path,
                        "pkg1",
                        "1.0",
                        "all",
                        existing_deb_path=shared_deb_path,
                    ).dump()
                    + "\n"
                )
            packages_paths.append(packages_path)
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, packages_paths
            )

        with patch.object(task, "get_release_path", return_value=release_path):
            plan = task.plan_binaries(temp_path)

        self.assertEqual(
            [(a.name, a.contents["Package"], a.component) for a in plan.add],
            [("pkg1_1.0_all", "pkg1", "main")],
        )
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [])

    def test_plan_binaries_replace(self) -> None:
        """`plan_binaries` plans to replace binaries in the collection."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm

        binary_package_artifacts = [
            self.create_binary_package_artifact(
                srcpkg_name=name,
                srcpkg_version=version,
                name=name,
                version=version,
                architecture="amd64",
                paths=[f"{name}_{version}_amd64.deb"],
            )
            for name, version in (("pkg1", "1.0"), ("pkg1", "1.1"))
        ]
        items = [
            collection.manager.add_artifact(
                binary_package_artifact,
                user=self.work_request.created_by,
                variables={
                    "component": "main",
                    "section": "devel",
                    "priority": "optional",
                },
            )
            for binary_package_artifact in binary_package_artifacts
        ]

        indexes_path = temp_path / "indexes"
        packages_path = (
            indexes_path / "dists/bookworm/main/binary-amd64/Packages"
        )
        packages_path.parent.mkdir(parents=True)
        with packages_path.open(mode="w") as packages:
            packages.write(
                self.write_sample_binary_package(
                    temp_path, "pkg1", "1.0", "amd64"
                ).dump()
                + "\n"
            )
            deb_1_1_file = (
                binary_package_artifacts[1].fileinartifact_set.get().file
            )
            packages.write(
                Packages(
                    {
                        "Package": "pkg1",
                        "Version": "1.1",
                        "Architecture": "amd64",
                        "Section": "devel",
                        "Priority": "optional",
                        "Filename": make_pool_filename(
                            "pkg1", "main", "pkg1_1.1_amd64.deb"
                        ),
                        "SHA256": deb_1_1_file.hash_digest.hex(),
                    }
                ).dump()
                + "\n"
            )
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (packages_path,)
            )

        with patch.object(
            self.task, "get_release_path", return_value=release_path
        ):
            plan = self.task.plan_binaries(temp_path)

        self.assertEqual(plan.add, [])
        self.assertEqual(
            [
                (r.name, r.contents["Package"], r.component, r.item)
                for r in plan.replace
            ],
            [("pkg1_1.0_amd64", "pkg1", "main", items[0])],
        )
        self.assertEqual(plan.remove, [])

    def test_plan_binaries_remove(self) -> None:
        """`plan_binaries` plans to remove binaries from the collection."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm

        binary_package_artifacts = [
            self.create_binary_package_artifact(
                srcpkg_name=name,
                srcpkg_version=version,
                name=name,
                version=version,
                architecture="amd64",
                paths=[f"{name}_{version}_amd64.deb"],
            )
            for name, version in (("pkg1", "1.0"), ("pkg2", "2.0"))
        ]
        items = [
            collection.manager.add_artifact(
                binary_package_artifact,
                user=self.work_request.created_by,
                variables={
                    "component": "main",
                    "section": "devel",
                    "priority": "optional",
                },
            )
            for binary_package_artifact in binary_package_artifacts
        ]

        indexes_path = temp_path / "indexes"
        packages_path = (
            indexes_path / "dists/bookworm/main/binary-amd64/Packages"
        )
        packages_path.parent.mkdir(parents=True)
        packages_path.touch()
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (packages_path,)
            )

        with patch.object(
            self.task, "get_release_path", return_value=release_path
        ):
            plan = self.task.plan_binaries(temp_path)

        self.assertEqual(plan.add, [])
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, items)

    def test_plan_binaries_inconsistent_different_binaries(self) -> None:
        """`plan_binaries` fails with conflicting binaries."""
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", architectures=["amd64", "s390x"]
        )

        indexes_path = temp_path / "indexes"
        packages_entries: dict[str, Packages] = {}
        packages_paths: list[Path] = []
        for architecture, srcpkg_version in (
            ("amd64", "1.0"),
            ("s390x", "1:1.0"),
        ):
            packages_entries[architecture] = self.write_sample_binary_package(
                temp_path, "pkg1", "1.0", "all", srcpkg_version=srcpkg_version
            )
            packages_path = (
                indexes_path
                / f"dists/bookworm/main/binary-{architecture}/Packages"
            )
            packages_path.parent.mkdir(parents=True)
            with packages_path.open(mode="w") as packages:
                packages.write(packages_entries[architecture].dump() + "\n")
            packages_paths.append(packages_path)
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, packages_paths
            )

        with patch.object(task, "get_release_path", return_value=release_path):
            self.assertRaisesRegex(
                InconsistentMirrorError,
                r"pkg1_1\.0_all mismatch.  Conflicting Packages entries:\n\n"
                + re.escape(packages_entries["amd64"].dump())
                + r"\n\n"
                + re.escape(packages_entries["s390x"].dump()),
                task.plan_binaries,
                temp_path,
            )

    def test_plan_binaries_inconsistent_different_component(self) -> None:
        """`plan_binaries` fails with the same binary in multiple components."""
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", components=["main", "contrib"]
        )

        indexes_path = temp_path / "indexes"
        packages_paths: list[Path] = []
        for component in ("main", "contrib"):
            packages_path = (
                indexes_path
                / f"dists/bookworm/{component}/binary-amd64/Packages"
            )
            packages_path.parent.mkdir(parents=True)
            with packages_path.open(mode="w") as packages:
                packages.write(
                    self.write_sample_binary_package(
                        temp_path, "pkg1", "1.0", "amd64"
                    ).dump()
                    + "\n"
                )
            packages_paths.append(packages_path)
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, packages_paths
            )

        with patch.object(task, "get_release_path", return_value=release_path):
            self.assertRaisesRegex(
                InconsistentMirrorError,
                r"pkg1_1\.0_amd64 found in multiple components: "
                r"main and contrib",
                task.plan_binaries,
                temp_path,
            )

    def test_plan_binaries_filters(self) -> None:
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm",
            filters=[
                APTMirrorFilter(priority="required"),
                APTMirrorFilter(section="text"),
                APTMirrorFilter(section="php", ignore_unauthorized=True),
                APTMirrorFilter(source_name=r"foo", binary_name=r"^libfoo.*"),
                APTMirrorFilter(binary_name=r"^python.*$"),
            ],
        )

        indexes_path = temp_path / "indexes"
        packages_path = (
            indexes_path / "dists/bookworm/main/binary-amd64/Packages"
        )
        packages_path.parent.mkdir(parents=True)
        with packages_path.open(mode="w") as packages:
            packages.writelines(
                self.write_sample_binary_package(
                    temp_path,
                    name,
                    "1.0",
                    "amd64",
                    srcpkg_name=srcpkg_name,
                    srcpkg_version=srcpkg_version,
                    section=section,
                    priority=priority,
                ).dump()
                + "\n"
                for name, srcpkg_name, srcpkg_version, section, priority in (
                    ("base-files", "base-files", "1.0", "admin", "required"),
                    ("groff-base", "groff", "1.0", "text", "standard"),
                    ("libfoo1", "foo", "1:1.0", "libs", "optional"),
                    ("libfoo-dev", "foo", "1:1.0", "libdevel", "optional"),
                    ("foo-doc", "foo", "1:1.0", "doc", "optional"),
                    ("php-foo", "php-foo", "1.0", "php", "optional"),
                    ("python3-foo", "python-foo", "1.0", "python", "optional"),
                    ("unwanted", "unwanted", "1.0", "misc", "optional"),
                )
            )
        release_path = temp_path / "Release"
        with release_path.open(mode="w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (packages_path,)
            )

        with patch.object(task, "get_release_path", return_value=release_path):
            plan = task.plan_binaries(temp_path)

        self.assertEqual(
            [(a.contents["Package"], a.ignore_unauthorized) for a in plan.add],
            [
                ("base-files", False),
                ("groff-base", False),
                ("libfoo-dev", False),
                ("libfoo1", False),
                ("php-foo", True),
                ("python3-foo", False),
            ],
        )
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [])

    def test_add_binary(self) -> None:
        """`add_binary` downloads and adds a binary package."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        packages_entry = self.write_sample_binary_package(
            temp_path,
            "libhello1",
            "1.0-1",
            "amd64",
            srcpkg_name="hello",
            srcpkg_version="1:1.0-1",
            section="libs",
        )
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            deb_contents = (
                temp_path / "libhello1_1.0-1_amd64.deb"
            ).read_bytes()
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/h/hello/"
                "libhello1_1.0-1_amd64.deb",
                body=deb_contents,
            )

            self.task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1.0-1_amd64",
                    contents=packages_entry,
                    component="main",
                ),
            )

        binary_item = collection.manager.lookup(
            "binary-version:libhello1_1.0-1_amd64"
        )
        assert binary_item is not None
        assert binary_item.artifact is not None
        self.assert_artifact_matches(
            binary_item.artifact,
            ArtifactCategory.BINARY_PACKAGE,
            self.task.workspace,
            {
                "srcpkg_name": "hello",
                "srcpkg_version": "1:1.0-1",
                "deb_fields": {
                    "Package": "libhello1",
                    "Version": "1.0-1",
                    "Architecture": "amd64",
                    "Maintainer": "Example Maintainer <example@example.org>",
                    "Description": "Example description",
                    "Source": "hello (1:1.0-1)",
                },
                "deb_control_files": ["control"],
            },
            {"libhello1_1.0-1_amd64.deb": deb_contents},
        )
        item = collection.child_items.get()
        self.assertEqual(item.created_by_user, self.work_request.created_by)
        self.assertEqual(item.data["component"], "main")
        self.assertEqual(item.data["section"], "libs")
        self.assertEqual(item.data["priority"], "optional")

    def test_add_binary_with_epoch(self) -> None:
        """`add_binary` handles downloaded binary packages with epochs."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        packages_entry = self.write_sample_binary_package(
            temp_path,
            "libhello1",
            "1:1.0-1",
            "amd64",
            srcpkg_name="hello",
            section="libs",
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            deb_contents = (
                temp_path / "libhello1_1.0-1_amd64.deb"
            ).read_bytes()
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/h/hello/"
                "libhello1_1.0-1_amd64.deb",
                body=deb_contents,
            )

            self.task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1:1.0-1_amd64",
                    contents=packages_entry,
                    component="main",
                ),
            )

        binary_item = collection.manager.lookup(
            "binary-version:libhello1_1:1.0-1_amd64"
        )
        assert binary_item is not None
        assert binary_item.artifact is not None
        self.assert_artifact_matches(
            binary_item.artifact,
            ArtifactCategory.BINARY_PACKAGE,
            self.task.workspace,
            {
                "srcpkg_name": "hello",
                "srcpkg_version": "1:1.0-1",
                "deb_fields": {
                    "Package": "libhello1",
                    "Version": "1:1.0-1",
                    "Architecture": "amd64",
                    "Maintainer": "Example Maintainer <example@example.org>",
                    "Description": "Example description",
                    "Source": "hello",
                },
                "deb_control_files": ["control"],
            },
            {"libhello1_1.0-1_amd64.deb": deb_contents},
        )
        item = collection.child_items.get()
        self.assertEqual(item.created_by_user, self.work_request.created_by)
        self.assertEqual(item.data["component"], "main")
        self.assertEqual(item.data["section"], "libs")
        self.assertEqual(item.data["priority"], "optional")

    def test_add_binary_relates_to_source(self) -> None:
        """`add_binary` adds a relation to a matching source package."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        source_package_artifacts = [
            self.create_source_package_artifact(
                name="hello", version=version, paths=[]
            )
            for version in ("1:1.0-1", "1:1.0-2")
        ]
        for source_package_artifact in source_package_artifacts:
            collection.manager.add_artifact(
                source_package_artifact,
                user=self.work_request.created_by,
                variables={"component": "main", "section": "devel"},
            )

        packages_entry = self.write_sample_binary_package(
            temp_path,
            "libhello1",
            "1.0-1",
            "amd64",
            srcpkg_name="hello",
            srcpkg_version="1:1.0-1",
            section="libs",
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            deb_contents = (
                temp_path / "libhello1_1.0-1_amd64.deb"
            ).read_bytes()
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/h/hello/"
                "libhello1_1.0-1_amd64.deb",
                body=deb_contents,
            )

            self.task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1.0-1_amd64",
                    contents=packages_entry,
                    component="main",
                ),
            )

        binary_item = collection.manager.lookup(
            "binary-version:libhello1_1.0-1_amd64"
        )
        assert binary_item is not None
        assert binary_item.artifact is not None
        relation = binary_item.artifact.relations.get()
        self.assertEqual(relation.target, source_package_artifacts[0])
        self.assertEqual(relation.type, ArtifactRelation.Relations.BUILT_USING)

    def test_add_binary_no_source_relation_if_may_reuse_versions(self) -> None:
        """`add_binary` doesn't add source relations if `may_reuse_versions`."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        collection.data["may_reuse_versions"] = True
        collection.save()
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        source_package_artifact = self.create_source_package_artifact(
            name="hello", version="1:1.0-1", paths=[]
        )
        collection.manager.add_artifact(
            source_package_artifact,
            user=self.work_request.created_by,
            variables={"component": "main", "section": "devel"},
        )

        packages_entry = self.write_sample_binary_package(
            temp_path,
            "libhello1",
            "1.0-1",
            "amd64",
            srcpkg_name="hello",
            srcpkg_version="1:1.0-1",
            section="libs",
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            deb_contents = (
                temp_path / "libhello1_1.0-1_amd64.deb"
            ).read_bytes()
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/h/hello/"
                "libhello1_1.0-1_amd64.deb",
                body=deb_contents,
            )

            self.task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1.0-1_amd64",
                    contents=packages_entry,
                    component="main",
                ),
            )

        binary_item = collection.manager.lookup(
            "binary-version:libhello1_1.0-1_amd64"
        )
        assert binary_item is not None
        assert binary_item.artifact is not None
        self.assertFalse(binary_item.artifact.relations.exists())

    def test_add_binary_already_in_parent_archive(self) -> None:
        """`add_binary` reuses matching artifacts from the parent archive."""
        temp_path = self.create_temporary_directory()
        user = self.playground.get_default_user()
        archive = self.playground.create_singleton_collection(
            CollectionCategory.ARCHIVE
        )
        archive.data["may_reuse_versions"] = True
        archive.save()
        bookworm = self.bookworm
        trixie = self.create_suite_collection("trixie")
        archive.manager.add_collection(bookworm, user=user)
        archive.manager.add_collection(trixie, user=user)
        task = self.create_apt_mirror_task("trixie")
        self.playground.create_group_role(
            task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[task.work_request.created_by],
        )

        existing = [
            self.playground.create_artifact_from_local(
                self.playground.create_binary_package(
                    temp_path,
                    name="libhello1",
                    version=version,
                    architecture="amd64",
                    source_name="hello",
                ),
                create_files=True,
            )
            for version in ("1.0-1", "1.0-2")
        ]
        for suite, artifact in ((bookworm, existing[0]), (trixie, existing[1])):
            suite.manager.add_artifact(
                artifact,
                user=user,
                variables={
                    "component": "main",
                    "section": "libs",
                    "priority": "optional",
                },
            )

        packages_entries = [
            self.write_sample_binary_package(
                temp_path,
                "libhello1",
                version,
                "amd64",
                srcpkg_name="hello",
                srcpkg_version=srcpkg_version,
                section="libs",
                existing_deb_path=existing_deb_path,
            )
            for version, srcpkg_version, existing_deb_path in (
                ("1.0-1", "1.0-1", temp_path / "libhello1_1.0-1_amd64.deb"),
                ("1.0-2", "1:1.0-2", None),
                ("1.0-3", "1:1.0-3", None),
            )
        ]

        task.work_request.set_current()

        # A binary package that is already in the archive is not downloaded.
        with responses.RequestsMock():
            task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1.0-1_amd64",
                    contents=packages_entries[0],
                    component="main",
                ),
            )

        # A binary package that has a matching version in the archive but
        # with the wrong checksum is downloaded.
        with responses.RequestsMock() as rsps:
            deb_contents = (
                temp_path / "libhello1_1.0-2_amd64.deb"
            ).read_bytes()
            rsps.add(
                responses.GET,
                f"{task.data.url}/pool/main/h/hello/libhello1_1.0-2_amd64.deb",
                body=deb_contents,
            )

            task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1.0-2_amd64",
                    contents=packages_entries[1],
                    component="main",
                ),
            )

        # A binary package that is not already in the archive is downloaded.
        with responses.RequestsMock() as rsps:
            deb_contents = (
                temp_path / "libhello1_1.0-3_amd64.deb"
            ).read_bytes()
            rsps.add(
                responses.GET,
                f"{task.data.url}/pool/main/h/hello/libhello1_1.0-3_amd64.deb",
                body=deb_contents,
            )

            task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1.0-3_amd64",
                    contents=packages_entries[2],
                    component="main",
                ),
            )

        copied_item = trixie.manager.lookup(
            "binary-version:libhello1_1.0-1_amd64"
        )
        assert copied_item is not None
        self.assertEqual(copied_item.artifact, existing[0])
        self.assertEqual(
            copied_item.created_by_user, task.work_request.created_by
        )
        self.assertEqual(copied_item.data["component"], "main")
        self.assertEqual(copied_item.data["section"], "libs")
        self.assertEqual(copied_item.data["priority"], "optional")

        for version in ("1.0-2", "1.0-3"):
            downloaded_item = trixie.manager.lookup(
                f"binary-version:libhello1_{version}_amd64"
            )
            assert downloaded_item is not None
            self.assertNotEqual(downloaded_item.artifact, existing[0])
            self.assertNotEqual(downloaded_item.artifact, existing[1])
            self.assertEqual(
                downloaded_item.created_by_user, task.work_request.created_by
            )
            self.assertEqual(downloaded_item.data["component"], "main")
            self.assertEqual(downloaded_item.data["section"], "libs")
            self.assertEqual(downloaded_item.data["priority"], "optional")

    def test_add_binary_can_rewind(self) -> None:
        """There may already be a newer version of this binary package."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        collection.manager.add_artifact(
            self.playground.create_minimal_binary_package_artifact(
                srcpkg_name="hello",
                srcpkg_version="1:1.0-2",
                package="libhello1",
                version="1.0-2",
                architecture="amd64",
            ),
            user=self.playground.get_default_user(),
            variables={
                "component": "main",
                "section": "libs",
                "priority": "optional",
            },
        )
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        packages_entry = self.write_sample_binary_package(
            temp_path,
            "libhello1",
            "1.0-1",
            "amd64",
            srcpkg_name="hello",
            srcpkg_version="1:1.0-1",
            section="libs",
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            deb_contents = (
                temp_path / "libhello1_1.0-1_amd64.deb"
            ).read_bytes()
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/h/hello/"
                "libhello1_1.0-1_amd64.deb",
                body=deb_contents,
            )

            self.task.add_binary(
                temp_path,
                PlanAdd[Packages](
                    name="libhello1_1.0-1_amd64",
                    contents=packages_entry,
                    component="main",
                ),
            )

        binary_item = collection.manager.lookup(
            "binary-version:libhello1_1.0-1_amd64"
        )
        assert binary_item is not None
        assert binary_item.artifact is not None
        self.assert_artifact_matches(
            binary_item.artifact,
            ArtifactCategory.BINARY_PACKAGE,
            self.task.workspace,
            {
                "srcpkg_name": "hello",
                "srcpkg_version": "1:1.0-1",
                "deb_fields": {
                    "Package": "libhello1",
                    "Version": "1.0-1",
                    "Architecture": "amd64",
                    "Maintainer": "Example Maintainer <example@example.org>",
                    "Description": "Example description",
                    "Source": "hello (1:1.0-1)",
                },
                "deb_control_files": ["control"],
            },
            {"libhello1_1.0-1_amd64.deb": deb_contents},
        )
        self.assertEqual(
            binary_item.created_by_user, self.work_request.created_by
        )
        self.assertEqual(binary_item.data["component"], "main")
        self.assertEqual(binary_item.data["section"], "libs")
        self.assertEqual(binary_item.data["priority"], "optional")

    def test_add_binary_ignore_unauthorized(self) -> None:
        """`add_binary` downloads and adds a binary package."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        hello_entry = self.write_sample_binary_package(
            temp_path, "hello", "1.0-1", "amd64"
        )
        skip_entry = self.write_sample_binary_package(
            temp_path, "skip", "1.0-1", "amd64"
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            deb_contents = (temp_path / "hello_1.0-1_amd64.deb").read_bytes()
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/h/hello/hello_1.0-1_amd64.deb",
                body=deb_contents,
            )
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/pool/main/s/skip/skip_1.0-1_amd64.deb",
                status=requests.codes.unauthorized,
            )

            for name, contents in (
                ("hello_1.0-1_amd64", hello_entry),
                ("skip_1.0-1_amd64", skip_entry),
            ):
                self.task.add_binary(
                    temp_path,
                    PlanAdd[Packages](
                        name=name,
                        contents=contents,
                        component="main",
                        ignore_unauthorized=True,
                    ),
                )

        self.assertIsNotNone(
            collection.manager.lookup("binary-version:hello_1.0-1_amd64")
        )
        self.assertIsNone(
            collection.manager.lookup("binary-version:skip_1.0-1_amd64")
        )

    def test_update_binaries(self) -> None:
        """`update_binaries` executes a plan to update binaries."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        collection.data["may_reuse_versions"] = True
        collection.save()
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        binary_package_artifacts = [
            self.create_binary_package_artifact(
                srcpkg_name=name,
                srcpkg_version=version,
                name=name,
                version=version,
                architecture="amd64",
                paths=[f"{name}_{version}_amd64.deb"],
            )
            for name, version in (("to-replace", "1.0"), ("to-remove", "1.0"))
        ]
        items = [
            collection.manager.add_artifact(
                binary_package_artifact,
                user=self.work_request.created_by,
                variables={
                    "component": "main",
                    "section": "devel",
                    "priority": "optional",
                },
            )
            for binary_package_artifact in binary_package_artifacts
        ]

        to_add_entry = self.write_sample_binary_package(
            temp_path, "to-add", "1.0", "amd64"
        )
        to_add_deb_contents = (temp_path / "to-add_1.0_amd64.deb").read_bytes()
        to_replace_entry = self.write_sample_binary_package(
            temp_path, "to-replace", "1.0", "amd64"
        )
        to_replace_deb_contents = (
            temp_path / "to-replace_1.0_amd64.deb"
        ).read_bytes()
        plan = Plan[Packages](
            add=[
                PlanAdd[Packages](
                    name="to-add_1.0_amd64",
                    contents=to_add_entry,
                    component="main",
                )
            ],
            replace=[
                PlanReplace[Packages](
                    name="to-replace_1.0_amd64",
                    contents=to_replace_entry,
                    component="main",
                    item=items[0],
                )
            ],
            remove=[items[1]],
        )
        self.work_request.set_current()

        with responses.RequestsMock() as rsps:
            for name in ("to-add_1.0_amd64.deb", "to-replace_1.0_amd64.deb"):
                src_name = name.split("_", 1)[0]
                rsps.add(
                    responses.GET,
                    f"{self.task.data.url}/pool/main/"
                    f"{make_source_prefix(src_name)}/{src_name}/{name}",
                    body=(temp_path / name).read_bytes(),
                )

            self.task.update_binaries(temp_path, plan)

        active_items = {
            item.name: item
            for item in collection.child_items.all()
            if item.removed_at is None
        }
        self.assertEqual(
            active_items.keys(), {"to-add_1.0_amd64", "to-replace_1.0_amd64"}
        )
        assert active_items["to-add_1.0_amd64"].artifact is not None
        self.assert_artifact_files_match(
            active_items["to-add_1.0_amd64"].artifact,
            {"to-add_1.0_amd64.deb": to_add_deb_contents},
        )
        assert active_items["to-replace_1.0_amd64"].artifact is not None
        self.assert_artifact_files_match(
            active_items["to-replace_1.0_amd64"].artifact,
            {"to-replace_1.0_amd64.deb": to_replace_deb_contents},
        )

    def test_plan_indexes_add(self) -> None:
        """``plan_indexes`` plans to add indexes to the collection."""
        temp_path = self.create_temporary_directory()

        with patch("subprocess.run"):
            self.task.fetch_meta_indexes(temp_path)

        indexes_path = temp_path / "indexes"
        sources_path = indexes_path / "dists/bookworm/main/source/Sources.xz"
        sources_path.parent.mkdir(parents=True)
        with lzma.open(sources_path, "wt", format=lzma.FORMAT_XZ) as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])
        packages_path = (
            indexes_path / "dists/bookworm/main/binary-amd64/Packages.xz"
        )
        packages_path.parent.mkdir(parents=True)
        with lzma.open(packages_path, "wt", format=lzma.FORMAT_XZ) as packages:
            self.write_sample_packages_file(temp_path, packages, ["hello"])
        lists_path = temp_path / "var/lib/apt/lists"
        release_path = (
            lists_path / "deb.debian.org_debian_dists_bookworm_Release"
        )
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm",
                release,
                (sources_path, packages_path),
            )

        plan = self.task.plan_indexes(temp_path)

        self.assertEqual(
            [(a.name, a.contents["Filename"], a.component) for a in plan.add],
            [
                ("Release", str(release_path), ""),
                ("main/binary-amd64/Packages.xz", str(packages_path), "main"),
                ("main/source/Sources.xz", str(sources_path), "main"),
            ],
        )
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [])

    def test_plan_indexes_add_flat(self) -> None:
        """``plan_indexes`` handles flat repositories."""
        temp_path = self.create_temporary_directory()
        task = self.create_apt_mirror_task(
            "bookworm", url="https://example.org/flat", suite="./"
        )

        with patch("subprocess.run"):
            task.fetch_meta_indexes(temp_path)

        (indexes_path := temp_path / "indexes").mkdir()
        sources_path = indexes_path / "Sources.gz"
        with gzip.open(sources_path, "wt") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])
        packages_path = indexes_path / "Packages.gz"
        with gzip.open(packages_path, "wt") as packages:
            self.write_sample_packages_file(temp_path, packages, ["hello"])
        lists_path = temp_path / "var/lib/apt/lists"
        release_path = lists_path / "example.org_flat_._Release"
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                indexes_path, release, (sources_path, packages_path)
            )

        plan = task.plan_indexes(temp_path)

        self.assertEqual(plan.add, [])
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [])

    def test_plan_indexes_replace(self) -> None:
        """``plan_indexes`` plans to replace indexes in the collection."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm

        with patch("subprocess.run"):
            self.task.fetch_meta_indexes(temp_path)

        indexes_path = temp_path / "indexes"
        sources_path = indexes_path / "dists/bookworm/main/source/Sources"
        sources_path.parent.mkdir(parents=True)
        with open(sources_path, "w") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])
        packages_path = (
            indexes_path / "dists/bookworm/main/binary-amd64/Packages"
        )
        packages_path.parent.mkdir(parents=True)
        with open(packages_path, "w") as packages:
            self.write_sample_packages_file(temp_path, packages, ["hello"])
        lists_path = temp_path / "var/lib/apt/lists"
        release_path = (
            lists_path / "deb.debian.org_debian_dists_bookworm_Release"
        )
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm",
                release,
                (sources_path, packages_path),
            )

        repository_index_artifacts = {
            path: self.playground.create_repository_index(
                PurePath(path).name, contents, skip_add_files_in_store=True
            )
            for path, contents in (
                ("main/source/Sources", sources_path.read_bytes()),
                ("main/binary-amd64/Packages", b"old Packages"),
                ("Release", b"old Release"),
            )
        }
        items = {
            path: collection.manager.add_artifact(
                repository_index_artifact,
                user=self.work_request.created_by,
                variables={"path": path},
            )
            for (
                path,
                repository_index_artifact,
            ) in repository_index_artifacts.items()
        }

        plan = self.task.plan_indexes(temp_path)

        self.assertEqual(plan.add, [])
        self.assertEqual(
            [
                (r.name, r.contents["Filename"], r.component, r.item)
                for r in plan.replace
            ],
            [
                ("Release", str(release_path), "", items["Release"]),
                (
                    "main/binary-amd64/Packages",
                    str(packages_path),
                    "main",
                    items["main/binary-amd64/Packages"],
                ),
            ],
        )
        self.assertEqual(plan.remove, [])

    def test_plan_indexes_remove(self) -> None:
        """``plan_indexes`` plans to remove indexes from the collection."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm

        with patch("subprocess.run"):
            self.task.fetch_meta_indexes(temp_path)

        indexes_path = temp_path / "indexes"
        sources_path = indexes_path / "dists/bookworm/main/source/Sources"
        sources_path.parent.mkdir(parents=True)
        with open(sources_path, "w") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])
        packages_path = (
            indexes_path / "dists/bookworm/main/binary-amd64/Packages"
        )
        packages_path.parent.mkdir(parents=True)
        with open(packages_path, "w") as packages:
            self.write_sample_packages_file(temp_path, packages, ["hello"])
        lists_path = temp_path / "var/lib/apt/lists"
        release_path = (
            lists_path / "deb.debian.org_debian_dists_bookworm_Release"
        )
        with open(release_path, "w") as release:
            self.write_sample_release_file(
                indexes_path / "dists/bookworm", release, (sources_path,)
            )

        repository_index_artifacts = {
            path: self.playground.create_repository_index(
                PurePath(path).name, contents, skip_add_files_in_store=True
            )
            for path, contents in (
                ("main/source/Sources", sources_path.read_bytes()),
                ("main/binary-amd64/Packages", packages_path.read_bytes()),
                ("Release", release_path.read_bytes()),
            )
        }
        items = {
            path: collection.manager.add_artifact(
                repository_index_artifact,
                user=self.work_request.created_by,
                variables={"path": path},
            )
            for (
                path,
                repository_index_artifact,
            ) in repository_index_artifacts.items()
        }

        plan = self.task.plan_indexes(temp_path)

        self.assertEqual(plan.add, [])
        self.assertEqual(plan.replace, [])
        self.assertEqual(plan.remove, [items["main/binary-amd64/Packages"]])

    def test_add_index(self) -> None:
        """``add_index`` adds a repository index."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        prefix = "deb.debian.org_debian_dists_bookworm"
        sources_path = temp_path / f"{prefix}_main_source_Sources.xz"
        with lzma.open(sources_path, "wt") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])

        self.work_request.set_current()
        self.task.add_index(
            name="main/source/Sources.xz",
            paragraph=Deb822(
                {
                    "MetaKey": "main/source/Sources",
                    "Filename": str(sources_path),
                }
            ),
        )

        sources_item = collection.manager.lookup("index:main/source/Sources.xz")
        assert sources_item is not None
        assert sources_item.artifact is not None
        self.assert_artifact_matches(
            sources_item.artifact,
            ArtifactCategory.REPOSITORY_INDEX,
            self.task.workspace,
            {"path": "main/source/Sources.xz"},
            {"Sources.xz": sources_path.read_bytes()},
        )
        self.assertEqual(
            sources_item.created_by_user, self.work_request.created_by
        )
        self.assertEqual(sources_item.data["path"], "main/source/Sources.xz")

    def test_update_indexes(self) -> None:
        """``update_indexes`` executes a plan to update repository indexes."""
        temp_path = self.create_temporary_directory()
        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        with patch("subprocess.run"):
            self.task.fetch_meta_indexes(temp_path)

        lists_path = temp_path / "var/lib/apt/lists"
        prefix = "deb.debian.org_debian_dists_bookworm"
        main_sources_path = lists_path / f"{prefix}_main_source_Sources"
        with open(main_sources_path, "w") as main_sources:
            self.write_sample_sources_file(temp_path, main_sources, ["hello"])
        contrib_sources_path = lists_path / f"{prefix}_contrib_source_Sources"
        with open(contrib_sources_path, "w") as contrib_sources:
            self.write_sample_sources_file(
                temp_path,
                contrib_sources,
                ["contrib-hello"],
                component="contrib",
            )
        release_path = lists_path / f"{prefix}_Release"
        with open(release_path, "w") as release:
            release.write(Release({"Suite": "example"}).dump() + "\n")

        repository_index_artifacts = {
            path: self.playground.create_repository_index(
                PurePath(path).name, contents, skip_add_files_in_store=True
            )
            for path, contents in (
                ("main/source/Sources", main_sources_path.read_bytes()),
                ("non-free/source/Sources", b"old non-free Sources"),
                ("Release", b"old Release"),
            )
        }
        items = {
            path: collection.manager.add_artifact(
                repository_index_artifact,
                user=self.work_request.created_by,
                variables={"path": path},
            )
            for (
                path,
                repository_index_artifact,
            ) in repository_index_artifacts.items()
        }

        plan = Plan[Deb822](
            add=[
                PlanAdd[Deb822](
                    name="contrib/source/Sources",
                    contents=Deb822(
                        {
                            "MetaKey": "contrib/source/Sources",
                            "Filename": str(contrib_sources_path),
                        }
                    ),
                    component="contrib",
                )
            ],
            replace=[
                PlanReplace[Deb822](
                    name="Release",
                    contents=Deb822(
                        {"MetaKey": "Release", "Filename": str(release_path)}
                    ),
                    component="",
                    item=items["Release"],
                )
            ],
            remove=[items["non-free/source/Sources"]],
        )

        self.work_request.set_current()
        self.task.update_indexes(plan)

        active_items = {
            item.name: item
            for item in collection.child_items.all()
            if item.removed_at is None
        }
        self.assertEqual(
            active_items.keys(),
            {
                "index:main/source/Sources",
                "index:contrib/source/Sources",
                "index:Release",
            },
        )
        assert active_items["index:main/source/Sources"].artifact is not None
        self.assert_artifact_files_match(
            active_items["index:main/source/Sources"].artifact,
            {"Sources": main_sources_path.read_bytes()},
        )
        assert active_items["index:contrib/source/Sources"].artifact is not None
        self.assert_artifact_files_match(
            active_items["index:contrib/source/Sources"].artifact,
            {"Sources": contrib_sources_path.read_bytes()},
        )
        assert active_items["index:Release"].artifact is not None
        self.assert_artifact_files_match(
            active_items["index:Release"].artifact,
            {"Release": release_path.read_bytes()},
        )

    def test_execute(self) -> None:
        """
        Executing the task runs through the full sequence.

        Most of the details are tested elsewhere, but we do enough to check
        that the task can add to its collection.
        """
        temp_path = self.create_temporary_directory()
        origin_path = temp_path / "origin"
        origin_sources_path = origin_path / "main/source/Sources"
        origin_sources_path.parent.mkdir(parents=True)
        with open(origin_sources_path, "w") as sources:
            self.write_sample_sources_file(temp_path, sources, ["hello"])
        origin_packages_path = origin_path / "main/binary-amd64/Packages"
        origin_packages_path.parent.mkdir(parents=True)
        with open(origin_packages_path, "w") as packages:
            self.write_sample_packages_file(
                temp_path, packages, ["hello"], architecture="amd64"
            )
        origin_release_path = origin_path / "Release"
        with open(origin_release_path, "w") as release:
            self.write_sample_release_file(
                origin_path,
                release,
                (origin_sources_path, origin_packages_path),
            )

        collection = self.bookworm
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        def fake_run(
            args: list[str], env: dict[str, str], **kwargs: Any
        ) -> CompletedProcess[str]:
            apt_lists_path = (
                Path(env["APT_CONFIG"]).parent.parent.parent
                / "var/lib/apt/lists"
            )
            prefix = "deb.debian.org_debian_dists_bookworm"
            release_path = apt_lists_path / f"{prefix}_Release"
            stdout = ""
            match args:
                case ["apt-get", "update"]:
                    shutil.copy(origin_release_path, release_path)
                case _ as unreachable:
                    raise AssertionError(
                        f"Unexpected subprocess arguments: {unreachable}"
                    )
            return CompletedProcess(
                args=args, returncode=0, stdout=stdout, stderr=""
            )

        # Pretend that a lock for another collection is held, to make sure
        # that such a lock doesn't interfere.
        with (
            responses.RequestsMock() as rsps,
            self.separate_connection(
                non_conflicting_alias := "non-conflicting-lock"
            ),
            advisory_lock(
                (LockType.APT_MIRROR, (collection.id + 1) & (2**31 - 1)),
                wait=False,
                using=non_conflicting_alias,
            ) as acquired,
            patch("subprocess.run", side_effect=fake_run),
        ):
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/dists/bookworm/main/source/Sources",
                body=origin_sources_path.read_bytes(),
            )
            rsps.add(
                responses.GET,
                f"{self.task.data.url}/dists/bookworm/main/binary-amd64/"
                "Packages",
                body=origin_packages_path.read_bytes(),
            )
            for hello_name in (
                "hello_1.0.dsc",
                "hello_1.0.tar.xz",
                "hello_1.0_amd64.deb",
            ):
                rsps.add(
                    responses.GET,
                    f"{self.task.data.url}/pool/main/h/hello/{hello_name}",
                    body=(temp_path / hello_name).read_bytes(),
                )

            assert acquired
            self.assertEqual(self.task.execute(), WorkRequestResults.SUCCESS)

        self.assertQuerySetEqual(
            collection.child_items.values_list("name", flat=True),
            [
                "hello_1.0",
                "hello_1.0_amd64",
                "index:Release",
                "index:main/binary-amd64/Packages",
                "index:main/source/Sources",
            ],
            ordered=False,
        )

    def test_execute_lock_error(self) -> None:
        """The task fails if its lock is already held."""
        collection = self.bookworm

        with (
            self.separate_connection(conflicting_alias := "conflicting-lock"),
            advisory_lock(
                (LockType.APT_MIRROR, collection.id & (2**31 - 1)),
                wait=False,
                using=conflicting_alias,
            ) as acquired,
            self.assertRaisesRegex(
                LockError,
                "Another APTMirror task for bookworm is already running",
            ),
        ):
            assert acquired
            self.task.execute()

    def test_label(self) -> None:
        """Test get_label."""
        task = self.create_apt_mirror_task(
            "bookworm", url="https://deb.example.org/", suite="./"
        )
        self.assertEqual(
            task.get_label(), "mirror bookworm from https://deb.example.org/"
        )
