From 11293d075675875f5a8a6186fd4b8dbf4dd5d603 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:16:31 -0400 Subject: [PATCH] ngclient: add download_target_bytes() Add an Updater API that downloads and verifies a target and returns its content as bytes instead of writing it into the local cache. sigstore-python and similar callers want the verified bytes in memory and don't need the file on disk. The URL-building logic is pulled out into a shared _target_file_url() helper so both download_target() and download_target_bytes() run the exact same length/hash verification against the same downloaded stream; only the output differs (write to disk vs return bytes). Fixes #1556 Signed-off-by: Chris (ChrisJr404) <11917633+ChrisJr404@users.noreply.github.com> --- tests/test_updater_fetch_target.py | 45 ++++++++++++++++++ tuf/ngclient/updater.py | 75 ++++++++++++++++++++++++------ 2 files changed, 107 insertions(+), 13 deletions(-) diff --git a/tests/test_updater_fetch_target.py b/tests/test_updater_fetch_target.py index ecf777c6f1..4a70e7549c 100644 --- a/tests/test_updater_fetch_target.py +++ b/tests/test_updater_fetch_target.py @@ -119,6 +119,51 @@ def test_fetch_target(self, target: TestTarget) -> None: self.assertEqual(path, updater.find_cached_target(info)) self.assertEqual(path, updater.find_cached_target(info, path)) + @utils.run_sub_tests_with_dataset(targets) + def test_fetch_target_bytes(self, target: TestTarget) -> None: + path = os.path.join(self.targets_dir, target.encoded_path) + + # Add target to repository + self.sim.targets.version += 1 + self.sim.add_target("targets", target.content, target.path) + self.sim.update_snapshot() + + updater = self._init_updater() + info = updater.get_targetinfo(target.path) + assert info is not None + + # download_target_bytes returns the verified content + self.assertEqual(target.content, updater.download_target_bytes(info)) + + # ...and does not touch the local cache + self.assertFalse(os.path.exists(path)) + self.assertIsNone(updater.find_cached_target(info)) + + def test_invalid_target_download_bytes(self) -> None: + target = TestTarget("targetpath", b"content", "targetpath") + + # Add target to repository + self.sim.targets.version += 1 + self.sim.add_target("targets", target.content, target.path) + self.sim.update_snapshot() + + updater = self._init_updater() + info = updater.get_targetinfo(target.path) + assert info is not None + + # Corrupt the file content to not match the hash + self.sim.target_files[target.path].data = b"conten@" + with self.assertRaises(RepositoryError): + updater.download_target_bytes(info) + + # Corrupt the file content to not match the length + self.sim.target_files[target.path].data = b"cont" + with self.assertRaises(RepositoryError): + updater.download_target_bytes(info) + + # Verify nothing is persisted in cache + self.assertIsNone(updater.find_cached_target(info)) + def test_download_targets_with_succinct_roles(self) -> None: self.sim.add_succinct_roles("targets", 8, "bin") self.sim.update_snapshot() diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py index 9b93053464..f101ab30f1 100644 --- a/tuf/ngclient/updater.py +++ b/tuf/ngclient/updater.py @@ -275,6 +275,67 @@ def download_target( filepath = self._generate_target_file_path(targetinfo) Path(filepath).parent.mkdir(exist_ok=True, parents=True) + full_url = self._target_file_url(targetinfo, target_base_url) + + with self._fetcher.download_file( + full_url, targetinfo.length + ) as target_file: + targetinfo.verify_length_and_hashes(target_file) + + target_file.seek(0) + with open(filepath, "wb") as destination_file: + shutil.copyfileobj(target_file, destination_file) + + logger.debug("Downloaded target %s", targetinfo.path) + return filepath + + def download_target_bytes( + self, + targetinfo: TargetFile, + target_base_url: str | None = None, + ) -> bytes: + """Download the target file specified by ``targetinfo`` as bytes. + + This is like ``download_target()`` but returns the verified target + content instead of writing it into the local cache. The local target + cache is neither read nor written, so ``find_cached_target()`` is not + consulted: use ``download_target()`` if caching is wanted. + + The whole target is buffered in memory (the content cannot be verified + until it has been downloaded in full), so this is not suitable for very + large targets. + + Args: + targetinfo: ``TargetFile`` from ``get_targetinfo()``. + target_base_url: Base URL used to form the final target + download URL. Default is the value provided in ``Updater()`` + + Raises: + ValueError: Invalid arguments + DownloadError: Download of the target file failed in some way + RepositoryError: Downloaded target failed to be verified in some way + + Returns: + Verified target file content + """ + + full_url = self._target_file_url(targetinfo, target_base_url) + + with self._fetcher.download_file( + full_url, targetinfo.length + ) as target_file: + targetinfo.verify_length_and_hashes(target_file) + + target_file.seek(0) + data: bytes = target_file.read() + + logger.debug("Downloaded target %s", targetinfo.path) + return data + + def _target_file_url( + self, targetinfo: TargetFile, target_base_url: str | None + ) -> str: + """Build the remote download URL for a target file.""" if target_base_url is None: if self._target_base_url is None: raise ValueError( @@ -292,19 +353,7 @@ def download_target( hashes = list(targetinfo.hashes.values()) dirname, sep, basename = target_filepath.rpartition("/") target_filepath = f"{dirname}{sep}{hashes[0]}.{basename}" - full_url = f"{target_base_url}{target_filepath}" - - with self._fetcher.download_file( - full_url, targetinfo.length - ) as target_file: - targetinfo.verify_length_and_hashes(target_file) - - target_file.seek(0) - with open(filepath, "wb") as destination_file: - shutil.copyfileobj(target_file, destination_file) - - logger.debug("Downloaded target %s", targetinfo.path) - return filepath + return f"{target_base_url}{target_filepath}" def _download_metadata( self, rolename: str, length: int, version: int | None = None