From 02b7af694d70b03a42883d46b01ae55ce7f888c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= Date: Fri, 3 Feb 2023 10:06:40 -0500 Subject: [PATCH 1/3] Create a FileFetcher that uses the HTTP downloader --- composer.json | 2 +- src/FileFetcher.php | 75 ++++++++++++++++++++++++++ src/TufValidatedComposerRepository.php | 3 +- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/FileFetcher.php diff --git a/composer.json b/composer.json index cf778d2..8f41dd2 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "require": { "composer-plugin-api": "^2.2", "php-tuf/php-tuf": "dev-main", - "guzzlehttp/guzzle": "^6.5 || ^7.2" + "guzzlehttp/psr7": "^1.7" }, "autoload": { "psr-4": { diff --git a/src/FileFetcher.php b/src/FileFetcher.php new file mode 100644 index 0000000..2dac2bf --- /dev/null +++ b/src/FileFetcher.php @@ -0,0 +1,75 @@ +doFetch($this->metadataBaseUrl . '/' . $fileName, $maxBytes); + } + + /** + * {@inheritdoc} + */ + public function fetchTarget(string $fileName, int $maxBytes): PromiseInterface + { + return $this->doFetch($this->targetsBaseUrl . '/' . $fileName, $maxBytes); + } + + /** + * {@inheritdoc} + */ + public function fetchMetadataIfExists(string $fileName, int $maxBytes): ?string + { + $onFailure = function (\Throwable $e) { + if ($e instanceof RepoFileNotFound) { + return null; + } else { + throw $e; + } + }; + return $this->fetchMetadata($fileName, $maxBytes) + ->then(null, $onFailure) + ->wait(); + } + + private function doFetch(string $url, int $maxBytes): PromiseInterface + { + // Work around a bug in Composer. + // @see \Tuf\ComposerIntegration\ComposerCompatibleUpdater::getLength() + $maxBytes++; + + try { + $content = $this->downloader->get($url, ['max_file_size' => $maxBytes]) + ->getBody(); + $stream = Utils::streamFor($content); + return Create::promiseFor($stream); + } catch (TransportException $e) { + if ($e->getStatusCode() === 404) { + $fileName = parse_url($url, PHP_URL_PATH); + $fileName = basename($fileName); + + $error = new RepoFileNotFound("$fileName not found"); + } else { + $error = new \RuntimeException($e->getMessage(), $e->getCode(), $e); + } + return Create::rejectionFor($error); + } + } +} diff --git a/src/TufValidatedComposerRepository.php b/src/TufValidatedComposerRepository.php index f3408e1..5914a17 100644 --- a/src/TufValidatedComposerRepository.php +++ b/src/TufValidatedComposerRepository.php @@ -11,7 +11,6 @@ use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; use GuzzleHttp\Psr7\Utils; -use Tuf\Client\GuzzleFileFetcher; use Tuf\Exception\NotFoundException; use Tuf\Metadata\RootMetadata; use Tuf\Metadata\StorageInterface; @@ -54,7 +53,7 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, if (isset($repoConfig['tuf'])) { $this->updater = new ComposerCompatibleUpdater( - GuzzleFileFetcher::createFromUri($url), + new FileFetcher($httpDownloader, "$url/metadata", "$url/targets"), [], // @todo: Write a custom implementation of FileStorage that stores repo keys to user's global composer cache? $this->initializeStorage($url, $config) From e4ea23db458b54372dff03a3c68efaf1828e931e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= Date: Fri, 3 Feb 2023 10:31:20 -0500 Subject: [PATCH 2/3] Add unit test coverage --- src/FileFetcher.php | 16 ++++---- tests/FileFetcherTest.php | 79 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 tests/FileFetcherTest.php diff --git a/src/FileFetcher.php b/src/FileFetcher.php index 2dac2bf..48c7d4b 100644 --- a/src/FileFetcher.php +++ b/src/FileFetcher.php @@ -2,12 +2,14 @@ namespace Tuf\ComposerIntegration; +use Composer\Downloader\MaxFileSizeExceededException; use Composer\Downloader\TransportException; use Composer\Util\HttpDownloader; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Utils; use Tuf\Client\RepoFileFetcherInterface; +use Tuf\Exception\DownloadSizeException; use Tuf\Exception\RepoFileNotFound; class FileFetcher implements RepoFileFetcherInterface @@ -51,21 +53,21 @@ public function fetchMetadataIfExists(string $fileName, int $maxBytes): ?string private function doFetch(string $url, int $maxBytes): PromiseInterface { - // Work around a bug in Composer. - // @see \Tuf\ComposerIntegration\ComposerCompatibleUpdater::getLength() - $maxBytes++; + $fileName = parse_url($url, PHP_URL_PATH); + $fileName = basename($fileName); try { - $content = $this->downloader->get($url, ['max_file_size' => $maxBytes]) + // Add 1 to $maxBytes to work around a bug in Composer. + // @see \Tuf\ComposerIntegration\ComposerCompatibleUpdater::getLength() + $content = $this->downloader->get($url, ['max_file_size' => $maxBytes + 1]) ->getBody(); $stream = Utils::streamFor($content); return Create::promiseFor($stream); } catch (TransportException $e) { if ($e->getStatusCode() === 404) { - $fileName = parse_url($url, PHP_URL_PATH); - $fileName = basename($fileName); - $error = new RepoFileNotFound("$fileName not found"); + } elseif ($e instanceof MaxFileSizeExceededException) { + $error = new DownloadSizeException("$fileName exceeded $maxBytes bytes"); } else { $error = new \RuntimeException($e->getMessage(), $e->getCode(), $e); } diff --git a/tests/FileFetcherTest.php b/tests/FileFetcherTest.php new file mode 100644 index 0000000..e9567bf --- /dev/null +++ b/tests/FileFetcherTest.php @@ -0,0 +1,79 @@ +prophesize(HttpDownloader::class); + $fetcher = new FileFetcher($downloader->reveal(), '/metadata', '/targets'); + + $downloader->get('/metadata/root.json', ['max_file_size' => 129]) + ->willReturn(new Response()) + ->shouldBeCalled(); + $this->assertInstanceOf(StreamInterface::class, $fetcher->fetchMetadata('root.json', 128)->wait()); + + $downloader->get('/targets/payload.zip', ['max_file_size' => 257]) + ->willReturn(new Response()) + ->shouldBeCalled(); + $this->assertInstanceOf(StreamInterface::class, $fetcher->fetchTarget('payload.zip', 256)->wait()); + + // Any TransportException with a 404 error could should be converted + // into a RepoFileNotFound exception. + $exception = new TransportException(); + $exception->setStatusCode(404); + $downloader->get('/metadata/bogus.txt', ['max_file_size' => 11]) + ->willThrow($exception) + ->shouldBeCalled(); + try { + $fetcher->fetchMetadata('bogus.txt', 10)->wait(); + $this->fail('Expected a RepoFileNotFound exception, but none was thrown.'); + } catch (RepoFileNotFound $e) { + $this->assertSame('bogus.txt not found', $e->getMessage()); + } + + // A MaxFileSizeExceededException should be converted into a + // DownloadSizeException. + $downloader->get('/targets/too_big.txt', ['max_file_size' => 11]) + ->willThrow(new MaxFileSizeExceededException()) + ->shouldBeCalled(); + try { + $fetcher->fetchTarget('too_big.txt', 10)->wait(); + $this->fail('Expected a DownloadSizeException, but none was thrown.'); + } catch (DownloadSizeException $e) { + $this->assertSame('too_big.txt exceeded 10 bytes', $e->getMessage()); + } + + // Any other TransportException should be wrapped in a + // \RuntimeException. + $originalException = new TransportException('Whiskey Tango Foxtrot', -32); + $downloader->get('/metadata/wtf.txt', ['max_file_size' => 11]) + ->willThrow($originalException) + ->shouldBeCalled(); + try { + $fetcher->fetchMetadata('wtf.txt', 10)->wait(); + $this->fail('Expected a RuntimeException, but none was thrown.'); + } catch (\RuntimeException $e) { + $this->assertSame($originalException->getMessage(), $e->getMessage()); + $this->assertSame($originalException->getCode(), $e->getCode()); + $this->assertSame($originalException, $e->getPrevious()); + } + } +} From bdd11a11992031df63f15638994ff495ea074e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=C3=A9na=20Proxima?= Date: Fri, 3 Feb 2023 16:17:56 -0500 Subject: [PATCH 3/3] Require promises lib for now --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8f41dd2..f1c9ff9 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "require": { "composer-plugin-api": "^2.2", "php-tuf/php-tuf": "dev-main", - "guzzlehttp/psr7": "^1.7" + "guzzlehttp/psr7": "^1.7", + "guzzlehttp/promises": "^1.5" }, "autoload": { "psr-4": {