← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:archive-translate-path into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:archive-translate-path into launchpad:master.

Commit message:
Add ArchiveAPI.translatePath

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/430332

This is available over internal XML-RPC, and will ultimately allow building a service to serve files from archives without needing access to a local file system where the archive's files have been published.  See https://docs.google.com/document/d/11etjE-PXDsYwFxVac4V9toNhRtCclDzZx1TfzcX8_nw.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:archive-translate-path into launchpad:master.
diff --git a/lib/lp/archivepublisher/diskpool.py b/lib/lp/archivepublisher/diskpool.py
index 17cbd99..c7b2576 100644
--- a/lib/lp/archivepublisher/diskpool.py
+++ b/lib/lp/archivepublisher/diskpool.py
@@ -6,13 +6,14 @@ __all__ = [
     "DiskPoolEntry",
     "FileAddActionEnum",
     "poolify",
+    "unpoolify",
 ]
 
 import logging
 import os
 import tempfile
-from pathlib import Path
-from typing import TYPE_CHECKING, Optional, Union
+from pathlib import Path, PurePath
+from typing import TYPE_CHECKING, Optional, Tuple, Union
 
 from lp.archivepublisher import HARDCODED_COMPONENT_ORDER
 from lp.services.librarian.utils import copy_and_close, sha1_from_path
@@ -26,17 +27,57 @@ from lp.soyuz.interfaces.publishing import (
 )
 
 
-def poolify(source: str, component: Optional[str] = None) -> Path:
-    """Poolify a given source and component name."""
+def get_source_prefix(source: str) -> str:
+    """Get the prefix for a pooled source package name.
+
+    In the Debian repository format, packages are published to directories
+    of the form `pool/<component>/<source prefix>/<source name>/`, perhaps
+    best described here::
+
+        https://lists.debian.org/debian-devel/2000/10/msg01340.html
+
+    The directory here called `<source prefix>` (there doesn't seem to be a
+    canonical term for this) is formed by taking the first character of the
+    source name, except when the source name starts with "lib" in which case
+    it's formed by taking the first four characters of the source name.
+    This was originally in order to behave reasonably on file systems such
+    as ext2, but is now entrenched and expected.
+    """
     if source.startswith("lib"):
-        path = Path(source[:4]) / source
+        return source[:4]
     else:
-        path = Path(source[:1]) / source
+        return source[:1]
+
+
+def poolify(source: str, component: Optional[str] = None) -> Path:
+    """Poolify a given source and component name."""
+    path = Path(get_source_prefix(source)) / source
     if component is not None:
         path = Path(component) / path
     return path
 
 
+def unpoolify(path: PurePath) -> Tuple[str, str, Optional[str]]:
+    """Take a path and unpoolify it.
+
+    Return a tuple of component, source, filename.
+    """
+    p = path.parts
+    if len(p) < 3 or len(p) > 4:
+        raise ValueError(
+            "Path '%s' is not in a valid pool form" % path.as_posix()
+        )
+    component, source_prefix, source = p[:3]
+    if source_prefix != get_source_prefix(source):
+        raise ValueError(
+            "Source prefix '%s' does not match source '%s'"
+            % (source_prefix, source)
+        )
+    if len(p) == 4:
+        return component, source, p[3]
+    return component, source, None
+
+
 def relative_symlink(src_path: Path, dst_path: Path) -> None:
     """Path.symlink_to replacement that creates relative symbolic links."""
     src_path = Path(os.path.normpath(str(src_path)))
diff --git a/lib/lp/archivepublisher/tests/test_pool.py b/lib/lp/archivepublisher/tests/test_pool.py
index 3c795e6..a4d4cf5 100644
--- a/lib/lp/archivepublisher/tests/test_pool.py
+++ b/lib/lp/archivepublisher/tests/test_pool.py
@@ -4,15 +4,12 @@
 """Tests for pool.py."""
 
 import hashlib
-import shutil
-import unittest
-from pathlib import Path
-from tempfile import mkdtemp
+from pathlib import Path, PurePath
 
 from lazr.enum import EnumeratedType, Item
 from zope.interface import alsoProvides, implementer
 
-from lp.archivepublisher.diskpool import DiskPool, poolify
+from lp.archivepublisher.diskpool import DiskPool, poolify, unpoolify
 from lp.services.log.logger import BufferLogger
 from lp.soyuz.enums import ArchiveRepositoryFormat
 from lp.soyuz.interfaces.files import (
@@ -20,6 +17,7 @@ from lp.soyuz.interfaces.files import (
     IPackageReleaseFile,
     ISourcePackageReleaseFile,
 )
+from lp.testing import TestCase
 
 
 class FakeArchive:
@@ -148,8 +146,8 @@ class PoolTestingFile:
         return self.checkExists(component) and not self.checkIsLink(component)
 
 
-class TestPoolification(unittest.TestCase):
-    def testPoolificationOkay(self):
+class TestPoolification(TestCase):
+    def test_poolify_ok(self):
         """poolify should poolify properly"""
         cases = (
             ("foo", "main", Path("main/f/foo")),
@@ -159,19 +157,50 @@ class TestPoolification(unittest.TestCase):
         for case in cases:
             self.assertEqual(case[2], poolify(case[0], case[1]))
 
+    def test_unpoolify_ok(self):
+        cases = (
+            (PurePath("main/f/foo"), "main", "foo", None),
+            (PurePath("main/f/foo/foo_1.0.dsc"), "main", "foo", "foo_1.0.dsc"),
+            (PurePath("universe/f/foo"), "universe", "foo", None),
+            (PurePath("main/libf/libfoo"), "main", "libfoo", None),
+        )
+        for path, component, source, filename in cases:
+            self.assertEqual((component, source, filename), unpoolify(path))
+
+    def test_unpoolify_too_short(self):
+        self.assertRaisesWithContent(
+            ValueError,
+            "Path 'main' is not in a valid pool form",
+            unpoolify,
+            PurePath("main"),
+        )
+
+    def test_unpoolify_too_long(self):
+        self.assertRaisesWithContent(
+            ValueError,
+            "Path 'main/f/foo/bar/baz' is not in a valid pool form",
+            unpoolify,
+            PurePath("main/f/foo/bar/baz"),
+        )
 
-class TestPool(unittest.TestCase):
+    def test_unpoolify_prefix_mismatch(self):
+        self.assertRaisesWithContent(
+            ValueError,
+            "Source prefix 'a' does not match source 'foo'",
+            unpoolify,
+            PurePath("main/a/foo"),
+        )
+
+
+class TestPool(TestCase):
     def setUp(self):
-        self.pool_path = mkdtemp()
-        self.temp_path = mkdtemp()
+        super().setUp()
+        self.pool_path = self.makeTemporaryDirectory()
+        self.temp_path = self.makeTemporaryDirectory()
         self.pool = DiskPool(
             FakeArchive(), self.pool_path, self.temp_path, BufferLogger()
         )
 
-    def tearDown(self):
-        shutil.rmtree(self.pool_path)
-        shutil.rmtree(self.temp_path)
-
     def testSimpleAdd(self):
         """Adding a new file should work."""
         foo = PoolTestingFile(
diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
index ab50238..37b81a7 100644
--- a/lib/lp/soyuz/interfaces/archive.py
+++ b/lib/lp/soyuz/interfaces/archive.py
@@ -792,6 +792,15 @@ class IArchiveSubscriberView(Interface):
         :return: A collection containing `BinaryPackagePublishingHistory`.
         """
 
+    def getPoolFileByPath(path):
+        """Return the `ILibraryFileAlias` for a path in this archive's pool.
+
+        :param path: A `PurePath` for where a source or binary package file
+            is published in this archive's pool, e.g.
+            "pool/main/p/package/package_1.dsc".
+        :return: An `ILibraryFileAlias`, or None.
+        """
+
 
 class IArchiveView(IHasBuildRecords):
     """Archive interface for operations restricted by view privilege."""
diff --git a/lib/lp/soyuz/interfaces/archiveapi.py b/lib/lp/soyuz/interfaces/archiveapi.py
index 6d18f04..b8acecf 100644
--- a/lib/lp/soyuz/interfaces/archiveapi.py
+++ b/lib/lp/soyuz/interfaces/archiveapi.py
@@ -41,3 +41,17 @@ class IArchiveAPI(Interface):
             is not equal to the selected token for this archive; otherwise
             None.
         """
+
+    def translatePath(archive_reference, path):
+        """Find the librarian URL for a relative path within an archive.
+
+        :param archive_reference: The reference form of the archive to check.
+        :param path: The relative path within the archive.  This should not
+            begin with a "/" character.
+
+        :return: A `NotFound` fault if `archive_reference` does not identify
+            an archive, or the archive's repository format is something
+            other than `ArchiveRepositoryFormat.DEBIAN`, or the path does
+            not identify a file that exists in this archive; otherwise a
+            librarian URL.
+        """
diff --git a/lib/lp/soyuz/interfaces/archivefile.py b/lib/lp/soyuz/interfaces/archivefile.py
index d104d9b..b72c54e 100644
--- a/lib/lp/soyuz/interfaces/archivefile.py
+++ b/lib/lp/soyuz/interfaces/archivefile.py
@@ -88,6 +88,7 @@ class IArchiveFileSet(Interface):
         archive,
         container=None,
         path=None,
+        sha256=None,
         only_condemned=False,
         eager_load=False,
     ):
@@ -96,6 +97,10 @@ class IArchiveFileSet(Interface):
         :param archive: Return files in this `IArchive`.
         :param container: Return only files with this container.
         :param path: Return only files with this path.
+        :param path_parent: Return only files whose immediate parent
+            directory is this path.
+        :param sha256: If not None, return only files with this SHA-256
+            checksum.
         :param only_condemned: If True, return only files with a
             scheduled_deletion_date set.
         :param eager_load: If True, preload related `LibraryFileAlias` and
diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
index 02581d4..f14b8f2 100644
--- a/lib/lp/soyuz/model/archive.py
+++ b/lib/lp/soyuz/model/archive.py
@@ -14,6 +14,7 @@ __all__ = [
 import re
 import typing
 from operator import attrgetter
+from pathlib import PurePath
 
 import six
 from lazr.lifecycle.event import ObjectCreatedEvent
@@ -49,8 +50,13 @@ from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.app.interfaces.security import IAuthorization
 from lp.app.validators.name import valid_name
 from lp.archivepublisher.debversion import Version
+from lp.archivepublisher.diskpool import unpoolify
 from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
-from lp.archiveuploader.utils import re_isadeb, re_issource
+from lp.archiveuploader.utils import (
+    determine_binary_file_type,
+    re_isadeb,
+    re_issource,
+)
 from lp.buildmaster.enums import BuildQueueStatus, BuildStatus
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSet
 from lp.buildmaster.interfaces.processor import IProcessorSet
@@ -2013,6 +2019,68 @@ class Archive(SQLBase):
             raise NotFoundError(filename)
         return archive_file
 
+    def getPoolFileByPath(
+        self, path: PurePath
+    ) -> typing.Optional[LibraryFileAlias]:
+        """See `IArchive`."""
+        try:
+            component, source, filename = unpoolify(PurePath(*path.parts[1:]))
+        except ValueError:
+            return None
+        if filename is None:
+            return None
+
+        store = IStore(LibraryFileAlias)
+        clauses = [
+            Component.name == component,
+            SourcePackageName.name == source,
+            LibraryFileAlias.filename == filename,
+        ]
+        # Decide whether to look for source or binary publications.  We
+        # could just try both and UNION them, but this query is likely to be
+        # hot and is complex enough as it is, so don't push our luck.
+        binary = determine_binary_file_type(filename) is not None
+        if binary:
+            xPPH = BinaryPackagePublishingHistory
+            xPF = BinaryPackageFile
+            # XXX cjwatson 20220922: Simplify this once
+            # BinaryPackagePublishingHistory.sourcepackagename has finished
+            # populating.
+            clauses.extend(
+                [
+                    BinaryPackagePublishingHistory.binarypackagerelease
+                    == BinaryPackageRelease.id,
+                    BinaryPackageRelease.build == BinaryPackageBuild.id,
+                    BinaryPackageBuild.source_package_name
+                    == SourcePackageName.id,
+                    BinaryPackagePublishingHistory.binarypackagerelease
+                    == BinaryPackageFile.binarypackagereleaseID,
+                ]
+            )
+        else:
+            xPPH = SourcePackagePublishingHistory
+            xPF = SourcePackageReleaseFile
+            clauses.extend(
+                [
+                    SourcePackagePublishingHistory.sourcepackagename
+                    == SourcePackageName.id,
+                    SourcePackagePublishingHistory.sourcepackagerelease
+                    == SourcePackageReleaseFile.sourcepackagereleaseID,
+                ]
+            )
+        clauses.extend(
+            [
+                xPPH.archive == self,
+                xPPH.component == Component.id,
+                xPPH.datepublished != None,
+                xPPH.dateremoved == None,
+                xPF.libraryfile == LibraryFileAlias.id,
+            ]
+        )
+        return (
+            store.find(LibraryFileAlias, *clauses).config(distinct=True).one()
+        )
+
     def getBinaryPackageRelease(self, name, version, archtag):
         """See `IArchive`."""
         from lp.soyuz.model.distroarchseries import DistroArchSeries
diff --git a/lib/lp/soyuz/model/archivefile.py b/lib/lp/soyuz/model/archivefile.py
index 9e240b5..933e6d7 100644
--- a/lib/lp/soyuz/model/archivefile.py
+++ b/lib/lp/soyuz/model/archivefile.py
@@ -9,6 +9,7 @@ __all__ = [
 ]
 
 import os.path
+import re
 
 import pytz
 from storm.databases.postgres import Returning
@@ -21,7 +22,7 @@ from lp.services.database.constants import UTC_NOW
 from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.interfaces import IMasterStore, IStore
 from lp.services.database.sqlbase import convert_storm_clause_to_string
-from lp.services.database.stormexpr import BulkUpdate
+from lp.services.database.stormexpr import BulkUpdate, RegexpMatch
 from lp.services.librarian.interfaces import ILibraryFileAliasSet
 from lp.services.librarian.model import LibraryFileAlias, LibraryFileContent
 from lp.soyuz.interfaces.archivefile import IArchiveFile, IArchiveFileSet
@@ -98,6 +99,8 @@ class ArchiveFileSet:
         archive,
         container=None,
         path=None,
+        path_parent=None,
+        sha256=None,
         only_condemned=False,
         eager_load=False,
     ):
@@ -109,6 +112,20 @@ class ArchiveFileSet:
             clauses.append(ArchiveFile.container == container)
         if path is not None:
             clauses.append(ArchiveFile.path == path)
+        if path_parent is not None:
+            clauses.append(
+                RegexpMatch(
+                    ArchiveFile.path, "^%s/[^/]+$" % re.escape(path_parent)
+                )
+            )
+        if sha256 is not None:
+            clauses.extend(
+                [
+                    ArchiveFile.library_file == LibraryFileAlias.id,
+                    LibraryFileAlias.contentID == LibraryFileContent.id,
+                    LibraryFileContent.sha256 == sha256,
+                ]
+            )
         if only_condemned:
             clauses.append(ArchiveFile.scheduled_deletion_date != None)
         archive_files = IStore(ArchiveFile).find(ArchiveFile, *clauses)
diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
index 6a8d792..6ab3352 100644
--- a/lib/lp/soyuz/tests/test_archive.py
+++ b/lib/lp/soyuz/tests/test_archive.py
@@ -7,6 +7,7 @@ import doctest
 import http.client
 import os.path
 from datetime import date, datetime, timedelta
+from pathlib import PurePath
 from urllib.parse import urlsplit
 
 import responses
@@ -3204,6 +3205,219 @@ class TestGetSourceFileByName(TestCaseWithFactory):
         )
 
 
+class TestGetPoolFileByPath(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_file_name_too_short(self):
+        archive = self.factory.makeArchive()
+        self.assertIsNone(
+            archive.getPoolFileByPath(PurePath("pool/nonexistent"))
+        )
+
+    def test_file_name_too_long(self):
+        archive = self.factory.makeArchive()
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath("pool/main/p/package/nonexistent/path")
+            )
+        )
+
+    def test_mismatched_source_prefix(self):
+        archive = self.factory.makeArchive()
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spph.sourcepackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1.dsc", db_only=True
+            ),
+        )
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath("pool/main/q/test-package/test-package_1.dsc")
+            )
+        )
+
+    def test_source_not_found(self):
+        archive = self.factory.makeArchive()
+        self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath("pool/main/t/test-package/test-package_1.dsc")
+            )
+        )
+
+    def test_source_wrong_component(self):
+        archive = self.factory.makeArchive()
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spph.sourcepackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1.dsc", db_only=True
+            ),
+        )
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath("pool/universe/t/test-package/test-package_1.dsc")
+            )
+        )
+
+    def test_source_wrong_source_package_name(self):
+        archive = self.factory.makeArchive()
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spph.sourcepackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1.dsc", db_only=True
+            ),
+        )
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath("pool/main/o/other-package/test-package_1.dsc")
+            )
+        )
+
+    def test_source_found(self):
+        archive = self.factory.makeArchive()
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        sprf = self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spph.sourcepackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1.dsc", db_only=True
+            ),
+        )
+        self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spph.sourcepackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1.tar.xz", db_only=True
+            ),
+        )
+        IStore(sprf).flush()
+        self.assertEqual(
+            sprf.libraryfile,
+            archive.getPoolFileByPath(
+                PurePath("pool/main/t/test-package/test-package_1.dsc")
+            ),
+        )
+
+    def test_binary_not_found(self):
+        archive = self.factory.makeArchive()
+        self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath("pool/main/t/test-package/test-package_1_amd64.deb")
+            )
+        )
+
+    def test_binary_wrong_component(self):
+        archive = self.factory.makeArchive()
+        bpph = self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.factory.makeBinaryPackageFile(
+            binarypackagerelease=bpph.binarypackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1_amd64.deb", db_only=True
+            ),
+        )
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath(
+                    "pool/universe/t/test-package/test-package_1_amd64.deb"
+                )
+            )
+        )
+
+    def test_binary_wrong_source_package_name(self):
+        archive = self.factory.makeArchive()
+        bpph = self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.factory.makeBinaryPackageFile(
+            binarypackagerelease=bpph.binarypackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1_amd64.deb", db_only=True
+            ),
+        )
+        self.assertIsNone(
+            archive.getPoolFileByPath(
+                PurePath(
+                    "pool/universe/o/other-package/test-package_1_amd64.deb"
+                )
+            )
+        )
+
+    def test_binary_found(self):
+        archive = self.factory.makeArchive()
+        bpph = self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        bpf = self.factory.makeBinaryPackageFile(
+            binarypackagerelease=bpph.binarypackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1_amd64.deb", db_only=True
+            ),
+        )
+        bpph2 = self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.factory.makeBinaryPackageFile(
+            binarypackagerelease=bpph2.binarypackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1_i386.deb", db_only=True
+            ),
+        )
+        IStore(bpf).flush()
+        self.assertEqual(
+            bpf.libraryfile,
+            archive.getPoolFileByPath(
+                PurePath("pool/main/t/test-package/test-package_1_amd64.deb")
+            ),
+        )
+
+
 class TestGetPublishedSources(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
diff --git a/lib/lp/soyuz/tests/test_archivefile.py b/lib/lp/soyuz/tests/test_archivefile.py
index e4457e2..ca295ad 100644
--- a/lib/lp/soyuz/tests/test_archivefile.py
+++ b/lib/lp/soyuz/tests/test_archivefile.py
@@ -117,6 +117,42 @@ class TestArchiveFile(TestCaseWithFactory):
             [archive_files[2]],
             archive_file_set.getByArchive(archives[1], only_condemned=True),
         )
+        self.assertContentEqual(
+            [archive_files[0]],
+            archive_file_set.getByArchive(
+                archives[0],
+                sha256=archive_files[0].library_file.content.sha256,
+            ),
+        )
+        self.assertContentEqual(
+            [], archive_file_set.getByArchive(archives[0], sha256="nonsense")
+        )
+
+    def test_getByArchive_path_parent(self):
+        archive = self.factory.makeArchive()
+        archive_files = [
+            self.factory.makeArchiveFile(archive=archive, path=path)
+            for path in (
+                "dists/jammy/InRelease",
+                "dists/jammy/Release",
+                "dists/jammy/main/binary-amd64/Release",
+            )
+        ]
+        archive_file_set = getUtility(IArchiveFileSet)
+        self.assertContentEqual(
+            archive_files[:2],
+            archive_file_set.getByArchive(archive, path_parent="dists/jammy"),
+        )
+        self.assertContentEqual(
+            [archive_files[2]],
+            archive_file_set.getByArchive(
+                archive, path_parent="dists/jammy/main/binary-amd64"
+            ),
+        )
+        self.assertContentEqual(
+            [],
+            archive_file_set.getByArchive(archive, path_parent="dists/xenial"),
+        )
 
     def test_scheduleDeletion(self):
         archive_files = [self.factory.makeArchiveFile() for _ in range(3)]
diff --git a/lib/lp/soyuz/xmlrpc/archive.py b/lib/lp/soyuz/xmlrpc/archive.py
index 18b2618..a23b7fa 100644
--- a/lib/lp/soyuz/xmlrpc/archive.py
+++ b/lib/lp/soyuz/xmlrpc/archive.py
@@ -8,6 +8,9 @@ __all__ = [
 ]
 
 import logging
+from pathlib import PurePath
+from typing import Optional, Union
+from xmlrpc.client import Fault
 
 from pymacaroons import Macaroon
 from zope.component import getUtility
@@ -17,9 +20,11 @@ from zope.security.proxy import removeSecurityProxy
 
 from lp.services.macaroons.interfaces import NO_USER, IMacaroonIssuer
 from lp.services.webapp import LaunchpadXMLRPCView
+from lp.soyuz.enums import ArchiveRepositoryFormat
 from lp.soyuz.interfaces.archive import IArchiveSet
 from lp.soyuz.interfaces.archiveapi import IArchiveAPI
 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthTokenSet
+from lp.soyuz.interfaces.archivefile import IArchiveFileSet
 from lp.xmlrpc import faults
 from lp.xmlrpc.helpers import return_fault
 
@@ -108,3 +113,124 @@ class ArchiveAPI(LaunchpadXMLRPCView):
         return self._checkArchiveAuthToken(
             archive_reference, username, password
         )
+
+    def _translatePathByHash(
+        self, archive_reference: str, archive, path: PurePath
+    ) -> Optional[str]:
+        suite = path.parts[1]
+        checksum_type = path.parts[-2]
+        checksum = path.parts[-1]
+        # We only publish by-hash files for a single checksum type at
+        # present.  See `lp.archivepublisher.publishing`.
+        if checksum_type != "SHA256":
+            return None
+
+        archive_file = (
+            getUtility(IArchiveFileSet)
+            .getByArchive(
+                archive=archive,
+                container="release:%s" % suite,
+                path_parent="/".join(path.parts[:-3]),
+                sha256=checksum,
+            )
+            .any()
+        )
+        if archive_file is None:
+            return None
+
+        log.info(
+            "%s: %s (by-hash) -> LFA %d",
+            archive_reference,
+            path.as_posix(),
+            archive_file.library_file.id,
+        )
+        return archive_file.library_file.getURL()
+
+    def _translatePathNonPool(
+        self, archive_reference: str, archive, path: PurePath
+    ) -> Optional[str]:
+        archive_file = (
+            getUtility(IArchiveFileSet)
+            .getByArchive(archive=archive, path=path.as_posix())
+            .one()
+        )
+        if archive_file is None:
+            return None
+
+        log.info(
+            "%s: %s (non-pool) -> LFA %d",
+            archive_reference,
+            path.as_posix(),
+            archive_file.library_file.id,
+        )
+        return archive_file.library_file.getURL()
+
+    def _translatePathPool(
+        self, archive_reference: str, archive, path: PurePath
+    ) -> Optional[str]:
+        lfa = archive.getPoolFileByPath(path)
+        if lfa is None:
+            return None
+
+        log.info(
+            "%s: %s (pool) -> LFA %d",
+            archive_reference,
+            path.as_posix(),
+            lfa.id,
+        )
+        return lfa.getURL()
+
+    @return_fault
+    def _translatePath(self, archive_reference: str, path: PurePath) -> str:
+        archive = getUtility(IArchiveSet).getByReference(archive_reference)
+        if archive is None:
+            log.info("%s: No archive found", archive_reference)
+            raise faults.NotFound(
+                message="No archive found for '%s'." % archive_reference
+            )
+        archive = removeSecurityProxy(archive)
+        if archive.repository_format != ArchiveRepositoryFormat.DEBIAN:
+            log.info(
+                "%s: Repository format is %s",
+                archive_reference,
+                archive.repository_format,
+            )
+            raise faults.NotFound(
+                message="Can't translate paths in '%s' with format %s."
+                % (archive_reference, archive.repository_format)
+            )
+
+        # Consider by-hash index files.
+        if path.parts[0] == "dists" and path.parts[2:][-3:-2] == ("by-hash",):
+            url = self._translatePathByHash(archive_reference, archive, path)
+            if url is not None:
+                return url
+
+        # Consider other non-pool files.
+        if path.parts[0] != "pool":
+            url = self._translatePathNonPool(archive_reference, archive, path)
+            if url is not None:
+                return url
+            log.info("%s: %s not found", archive_reference, path.as_posix())
+            raise faults.NotFound(
+                message="'%s' not found in '%s'."
+                % (path.as_posix(), archive_reference)
+            )
+
+        # Consider pool files.
+        url = self._translatePathPool(archive_reference, archive, path)
+        if url is not None:
+            return url
+        log.info("%s: %s not found", archive_reference, path.as_posix())
+        raise faults.NotFound(
+            message="'%s' not found in '%s'."
+            % (path.as_posix(), archive_reference)
+        )
+
+    def translatePath(
+        self, archive_reference: str, path: str
+    ) -> Union[str, Fault]:
+        """See `IArchiveAPI`."""
+        # This thunk exists because you can't use a decorated function as
+        # the implementation of a method exported over XML-RPC.
+        return self._translatePath(archive_reference, PurePath(path))
diff --git a/lib/lp/soyuz/xmlrpc/tests/test_archive.py b/lib/lp/soyuz/xmlrpc/tests/test_archive.py
index dce28aa..cdb7d7d 100644
--- a/lib/lp/soyuz/xmlrpc/tests/test_archive.py
+++ b/lib/lp/soyuz/xmlrpc/tests/test_archive.py
@@ -8,8 +8,10 @@ from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.buildmaster.enums import BuildStatus
+from lp.services.database.interfaces import IStore
 from lp.services.features.testing import FeatureFixture
 from lp.services.macaroons.interfaces import IMacaroonIssuer
+from lp.soyuz.enums import ArchiveRepositoryFormat, PackagePublishingStatus
 from lp.soyuz.interfaces.archive import NAMED_AUTH_TOKEN_FEATURE_FLAG
 from lp.soyuz.xmlrpc.archive import ArchiveAPI
 from lp.testing import TestCaseWithFactory
@@ -33,53 +35,48 @@ class TestArchiveAPI(TestCaseWithFactory):
     def assertLogs(self, message):
         self.assertEqual([message], self.logger.output.splitlines())
 
-    def assertNotFound(
-        self, archive_reference, username, password, message, log_message
-    ):
-        """Assert that an archive auth token check returns NotFound."""
-        fault = self.archive_api.checkArchiveAuthToken(
-            archive_reference, username, password
-        )
+    def assertNotFound(self, func_name, message, log_message, *args, **kwargs):
+        """Assert that a call returns NotFound."""
+        fault = getattr(self.archive_api, func_name)(*args, **kwargs)
         self.assertEqual(faults.NotFound(message), fault)
         self.assertLogs(log_message)
 
-    def assertUnauthorized(
-        self, archive_reference, username, password, log_message
-    ):
-        """Assert that an archive auth token check returns Unauthorized."""
-        fault = self.archive_api.checkArchiveAuthToken(
-            archive_reference, username, password
-        )
+    def assertUnauthorized(self, func_name, log_message, *args, **kwargs):
+        """Assert that a call returns Unauthorized."""
+        fault = getattr(self.archive_api, func_name)(*args, **kwargs)
         self.assertEqual(faults.Unauthorized("Authorisation required."), fault)
         self.assertLogs(log_message)
 
     def test_checkArchiveAuthToken_unknown_archive(self):
         self.assertNotFound(
+            "checkArchiveAuthToken",
+            "No archive found for '~nonexistent/unknown/bad'.",
+            "user@~nonexistent/unknown/bad: No archive found",
             "~nonexistent/unknown/bad",
             "user",
             "",
-            "No archive found for '~nonexistent/unknown/bad'.",
-            "user@~nonexistent/unknown/bad: No archive found",
         )
 
     def test_checkArchiveAuthToken_no_tokens(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         self.assertNotFound(
+            "checkArchiveAuthToken",
+            "No valid tokens for 'nobody' in '%s'." % archive.reference,
+            "nobody@%s: No valid tokens" % archive.reference,
             archive.reference,
             "nobody",
             "",
-            "No valid tokens for 'nobody' in '%s'." % archive.reference,
-            "nobody@%s: No valid tokens" % archive.reference,
         )
 
     def test_checkArchiveAuthToken_no_named_tokens(self):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         self.assertNotFound(
+            "checkArchiveAuthToken",
+            "No valid tokens for '+missing' in '%s'." % archive.reference,
+            "+missing@%s: No valid tokens" % archive.reference,
             archive.reference,
             "+missing",
             "",
-            "No valid tokens for '+missing' in '%s'." % archive.reference,
-            "+missing@%s: No valid tokens" % archive.reference,
         )
 
     def test_checkArchiveAuthToken_buildd_macaroon_wrong_archive(self):
@@ -94,11 +91,12 @@ class TestArchiveAPI(TestCaseWithFactory):
         )
         macaroon = issuer.issueMacaroon(build)
         self.assertUnauthorized(
+            "checkArchiveAuthToken",
+            "buildd@%s: Macaroon verification failed"
+            % other_archive.reference,
             other_archive.reference,
             "buildd",
             macaroon.serialize(),
-            "buildd@%s: Macaroon verification failed"
-            % other_archive.reference,
         )
 
     def test_checkArchiveAuthToken_buildd_macaroon_not_building(self):
@@ -109,10 +107,11 @@ class TestArchiveAPI(TestCaseWithFactory):
         )
         macaroon = issuer.issueMacaroon(build)
         self.assertUnauthorized(
+            "checkArchiveAuthToken",
+            "buildd@%s: Macaroon verification failed" % archive.reference,
             archive.reference,
             "buildd",
             macaroon.serialize(),
-            "buildd@%s: Macaroon verification failed" % archive.reference,
         )
 
     def test_checkArchiveAuthToken_buildd_macaroon_wrong_user(self):
@@ -124,11 +123,12 @@ class TestArchiveAPI(TestCaseWithFactory):
         )
         macaroon = issuer.issueMacaroon(build)
         self.assertNotFound(
+            "checkArchiveAuthToken",
+            "No valid tokens for 'another-user' in '%s'." % archive.reference,
+            "another-user@%s: No valid tokens" % archive.reference,
             archive.reference,
             "another-user",
             macaroon.serialize(),
-            "No valid tokens for 'another-user' in '%s'." % archive.reference,
-            "another-user@%s: No valid tokens" % archive.reference,
         )
 
     def test_checkArchiveAuthToken_buildd_macaroon_correct(self):
@@ -150,10 +150,11 @@ class TestArchiveAPI(TestCaseWithFactory):
         archive = removeSecurityProxy(self.factory.makeArchive(private=True))
         token = archive.newNamedAuthToken("special")
         self.assertUnauthorized(
+            "checkArchiveAuthToken",
+            "+special@%s: Password does not match" % archive.reference,
             archive.reference,
             "+special",
             token.token + "-bad",
-            "+special@%s: Password does not match" % archive.reference,
         )
 
     def test_checkArchiveAuthToken_named_token_deactivated(self):
@@ -161,11 +162,12 @@ class TestArchiveAPI(TestCaseWithFactory):
         token = archive.newNamedAuthToken("special")
         removeSecurityProxy(token).deactivate()
         self.assertNotFound(
+            "checkArchiveAuthToken",
+            "No valid tokens for '+special' in '%s'." % archive.reference,
+            "+special@%s: No valid tokens" % archive.reference,
             archive.reference,
             "+special",
             token.token,
-            "No valid tokens for '+special' in '%s'." % archive.reference,
-            "+special@%s: No valid tokens" % archive.reference,
         )
 
     def test_checkArchiveAuthToken_named_token_correct_password(self):
@@ -184,11 +186,12 @@ class TestArchiveAPI(TestCaseWithFactory):
         archive.newSubscription(subscriber, archive.owner)
         token = archive.newAuthToken(subscriber)
         self.assertUnauthorized(
+            "checkArchiveAuthToken",
+            "%s@%s: Password does not match"
+            % (subscriber.name, archive.reference),
             archive.reference,
             subscriber.name,
             token.token + "-bad",
-            "%s@%s: Password does not match"
-            % (subscriber.name, archive.reference),
         )
 
     def test_checkArchiveAuthToken_personal_token_deactivated(self):
@@ -198,12 +201,13 @@ class TestArchiveAPI(TestCaseWithFactory):
         token = archive.newAuthToken(subscriber)
         removeSecurityProxy(token).deactivate()
         self.assertNotFound(
-            archive.reference,
-            subscriber.name,
-            token.token,
+            "checkArchiveAuthToken",
             "No valid tokens for '%s' in '%s'."
             % (subscriber.name, archive.reference),
             "%s@%s: No valid tokens" % (subscriber.name, archive.reference),
+            archive.reference,
+            subscriber.name,
+            token.token,
         )
 
     def test_checkArchiveAuthToken_personal_token_cancelled(self):
@@ -213,12 +217,13 @@ class TestArchiveAPI(TestCaseWithFactory):
         token = archive.newAuthToken(subscriber)
         removeSecurityProxy(subscription).cancel(archive.owner)
         self.assertNotFound(
-            archive.reference,
-            subscriber.name,
-            token.token,
+            "checkArchiveAuthToken",
             "No valid tokens for '%s' in '%s'."
             % (subscriber.name, archive.reference),
             "%s@%s: No valid tokens" % (subscriber.name, archive.reference),
+            archive.reference,
+            subscriber.name,
+            token.token,
         )
 
     def test_checkArchiveAuthToken_personal_token_correct_password(self):
@@ -234,3 +239,230 @@ class TestArchiveAPI(TestCaseWithFactory):
         self.assertLogs(
             "%s@%s: Authorized" % (subscriber.name, archive.reference)
         )
+
+    def test_translatePath_unknown_archive(self):
+        self.assertNotFound(
+            "translatePath",
+            "No archive found for '~nonexistent/unknown/bad'.",
+            "~nonexistent/unknown/bad: No archive found",
+            "~nonexistent/unknown/bad",
+            "dists/jammy/InRelease",
+        )
+
+    def test_translatePath_non_debian_archive(self):
+        archive = removeSecurityProxy(
+            self.factory.makeArchive(
+                repository_format=ArchiveRepositoryFormat.PYTHON
+            )
+        )
+        self.assertNotFound(
+            "translatePath",
+            "Can't translate paths in '%s' with format Python."
+            % archive.reference,
+            "%s: Repository format is Python" % archive.reference,
+            archive.reference,
+            "dists/jammy/InRelease",
+        )
+
+    def test_translatePath_by_hash_unsupported_checksum(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        archive_file = self.factory.makeArchiveFile(
+            archive=archive,
+            container="release:jammy",
+            path="dists/jammy/InRelease",
+        )
+        path = (
+            "dists/jammy/by-hash/SHA1/%s"
+            % archive_file.library_file.content.sha1
+        )
+        self.assertNotFound(
+            "translatePath",
+            "'%s' not found in '%s'." % (path, archive.reference),
+            "%s: %s not found" % (archive.reference, path),
+            archive.reference,
+            path,
+        )
+
+    def test_translatePath_by_hash_checksum_not_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        self.factory.makeArchiveFile(
+            archive=archive,
+            container="release:jammy",
+            path="dists/jammy/InRelease",
+        )
+        path = "dists/jammy/by-hash/SHA256/nonexistent"
+        self.assertNotFound(
+            "translatePath",
+            "'%s' not found in '%s'." % (path, archive.reference),
+            "%s: %s not found" % (archive.reference, path),
+            archive.reference,
+            path,
+        )
+
+    def test_translatePath_by_hash_checksum_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        self.factory.makeArchiveFile(
+            archive=archive,
+            container="release:jammy",
+            path="dists/jammy/InRelease",
+        )
+        archive_file = self.factory.makeArchiveFile(
+            archive=archive,
+            container="release:jammy",
+            path="dists/jammy/InRelease",
+        )
+        path = (
+            "dists/jammy/by-hash/SHA256/%s"
+            % archive_file.library_file.content.sha256
+        )
+        self.assertEqual(
+            archive_file.library_file.getURL(),
+            self.archive_api.translatePath(archive.reference, path),
+        )
+        self.assertLogs(
+            "%s: %s (by-hash) -> LFA %d"
+            % (archive.reference, path, archive_file.library_file.id)
+        )
+
+    def test_translatePath_non_pool_not_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        self.factory.makeArchiveFile(archive=archive)
+        self.assertNotFound(
+            "translatePath",
+            "'nonexistent/path' not found in '%s'." % archive.reference,
+            "%s: nonexistent/path not found" % archive.reference,
+            archive.reference,
+            "nonexistent/path",
+        )
+
+    def test_translatePath_non_pool_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        self.factory.makeArchiveFile(archive=archive)
+        archive_file = self.factory.makeArchiveFile(archive=archive)
+        self.assertEqual(
+            archive_file.library_file.getURL(),
+            self.archive_api.translatePath(
+                archive.reference, archive_file.path
+            ),
+        )
+        self.assertLogs(
+            "%s: %s (non-pool) -> LFA %d"
+            % (
+                archive.reference,
+                archive_file.path,
+                archive_file.library_file.id,
+            )
+        )
+
+    def test_translatePath_pool_bad_file_name(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        path = "pool/nonexistent"
+        self.assertNotFound(
+            "translatePath",
+            "'%s' not found in '%s'." % (path, archive.reference),
+            "%s: %s not found" % (archive.reference, path),
+            archive.reference,
+            path,
+        )
+
+    def test_translatePath_pool_source_not_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        path = "pool/main/t/test-package/test-package_1.dsc"
+        self.assertNotFound(
+            "translatePath",
+            "'%s' not found in '%s'." % (path, archive.reference),
+            "%s: %s not found" % (archive.reference, path),
+            archive.reference,
+            path,
+        )
+
+    def test_translatePath_pool_source_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        sprf = self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spph.sourcepackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1.dsc", db_only=True
+            ),
+        )
+        self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spph.sourcepackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1.tar.xz", db_only=True
+            ),
+        )
+        IStore(sprf).flush()
+        path = "pool/main/t/test-package/test-package_1.dsc"
+        self.assertEqual(
+            sprf.libraryfile.getURL(),
+            self.archive_api.translatePath(archive.reference, path),
+        )
+        self.assertLogs(
+            "%s: %s (pool) -> LFA %d"
+            % (archive.reference, path, sprf.libraryfile.id)
+        )
+
+    def test_translatePath_pool_binary_not_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        path = "pool/main/t/test-package/test-package_1_amd64.deb"
+        self.assertNotFound(
+            "translatePath",
+            "'%s' not found in '%s'." % (path, archive.reference),
+            "%s: %s not found" % (archive.reference, path),
+            archive.reference,
+            path,
+        )
+
+    def test_translatePath_pool_binary_found(self):
+        archive = removeSecurityProxy(self.factory.makeArchive())
+        bpph = self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        bpf = self.factory.makeBinaryPackageFile(
+            binarypackagerelease=bpph.binarypackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1_amd64.deb", db_only=True
+            ),
+        )
+        bpph2 = self.factory.makeBinaryPackagePublishingHistory(
+            archive=archive,
+            status=PackagePublishingStatus.PUBLISHED,
+            sourcepackagename="test-package",
+            component="main",
+        )
+        self.factory.makeBinaryPackageFile(
+            binarypackagerelease=bpph2.binarypackagerelease,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-package_1_i386.deb", db_only=True
+            ),
+        )
+        IStore(bpf).flush()
+        path = "pool/main/t/test-package/test-package_1_amd64.deb"
+        self.assertEqual(
+            bpf.libraryfile.getURL(),
+            self.archive_api.translatePath(archive.reference, path),
+        )
+        self.assertLogs(
+            "%s: %s (pool) -> LFA %d"
+            % (archive.reference, path, bpf.libraryfile.id)
+        )

Follow ups