diff --git a/composer.json b/composer.json index 35eceff..df552a5 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "require": { "composer-plugin-api": "^2.6", "php-tuf/php-tuf": "0.1.6", - "guzzlehttp/psr7": "^2.4" + "guzzlehttp/psr7": "^2.4", + "guzzlehttp/guzzle": "^7.8" }, "autoload": { "psr-4": { @@ -36,8 +37,7 @@ "require-dev": { "composer/composer": "^2.1", "phpunit/phpunit": "^9.5", - "symfony/process": "^6", - "dms/phpunit-arraysubset-asserts": "^0.5.0" + "symfony/process": "^6" }, "scripts": { "test": [ diff --git a/src/Loader.php b/src/Loader.php index 14243ee..5875a47 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -2,13 +2,15 @@ namespace Tuf\ComposerIntegration; -use Composer\Downloader\MaxFileSizeExceededException; -use Composer\Downloader\TransportException; use Composer\InstalledVersions; -use Composer\Util\HttpDownloader; -use GuzzleHttp\Promise\Create; +use Composer\IO\IOInterface; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Utils; +use GuzzleHttp\RequestOptions; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Tuf\Exception\DownloadSizeException; use Tuf\Exception\NotFoundException; use Tuf\Loader\LoaderInterface; @@ -19,9 +21,9 @@ class Loader implements LoaderInterface { public function __construct( - private HttpDownloader $downloader, - private ComposerFileStorage $storage, - private string $baseUrl = '' + private readonly ComposerFileStorage $storage, + private readonly IOInterface $io, + private readonly ClientInterface $client, ) {} /** @@ -29,15 +31,19 @@ public function __construct( */ public function load(string $locator, int $maxBytes): PromiseInterface { - $url = $this->baseUrl . $locator; - - $options = [ + $options = []; + // Try to enforce the maximum download size during transport. This will only have an effect + // if cURL is in use. + $options[RequestOptions::PROGRESS] = function (int $expectedBytes, int $bytesSoFar) use ($locator, $maxBytes): void + { // Add 1 to $maxBytes to work around a bug in Composer. // @see \Tuf\ComposerIntegration\ComposerCompatibleUpdater::getLength() - 'max_file_size' => $maxBytes + 1, - ]; + if ($bytesSoFar > $maxBytes + 1) { + throw new DownloadSizeException("$locator exceeded $maxBytes bytes"); + } + }; // Always send a X-PHP-TUF header with version information. - $options['http']['header'][] = self::versionHeader(); + $options[RequestOptions::HEADERS]['X-PHP-TUF'] = self::versionHeader(); // The name of the file in persistent storage will differ from $locator. $name = basename($locator, '.json'); @@ -46,43 +52,36 @@ public function load(string $locator, int $maxBytes): PromiseInterface $modifiedTime = $this->storage->getModifiedTime($name); if ($modifiedTime) { // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since. - $options['http']['header'][] = 'If-Modified-Since: ' . $modifiedTime->format('D, d M Y H:i:s') . ' GMT'; + $options[RequestOptions::HEADERS]['If-Modified-Since'] = $modifiedTime->format('D, d M Y H:i:s') . ' GMT'; } - try { - $response = $this->downloader->get($url, $options); - } catch (TransportException $e) { - if ($e->getStatusCode() === 404) { - throw new NotFoundException($locator); - } elseif ($e instanceof MaxFileSizeExceededException) { - throw new DownloadSizeException("$locator exceeded $maxBytes bytes"); + $onSuccess = function (ResponseInterface $response) use ($name, $locator): StreamInterface { + $status = $response->getStatusCode(); + $this->io->debug("[TUF] $status: '$locator'"); + + // If we sent an If-Modified-Since header and received a 304 (Not Modified) + // response, we can just load the file from cache. + if ($status === 304) { + $content = Utils::tryFopen($this->storage->toPath($name), 'r'); + return Utils::streamFor($content); } else { - throw new \RuntimeException($e->getMessage(), $e->getCode(), $e); + return $response->getBody(); } - } - - // If we sent an If-Modified-Since header and received a 304 (Not Modified) - // response, we can just load the file from cache. - if ($response->getStatusCode() === 304) { - $content = Utils::tryFopen($this->storage->toPath($name), 'r'); - } else { - // To prevent the static cache from running out of memory, write the response - // contents to a temporary stream (which will turn into a temporary file once - // once we've written 1024 bytes to it), which will be automatically cleaned - // up when it is garbage collected. - $content = Utils::tryFopen('php://temp/maxmemory:1024', 'r+'); - fwrite($content, $response->getBody()); - } + }; + $onFailure = function (\Throwable $e) use ($locator): never { + if ($e instanceof ClientException && $e->getCode() === 404) { + throw new NotFoundException($locator); + } + throw $e; + }; - $stream = Utils::streamFor($content); - $stream->rewind(); - return Create::promiseFor($stream); + return $this->client->getAsync($locator, $options)->then($onSuccess, $onFailure); } private static function versionHeader(): string { return sprintf( - 'X-PHP-TUF: client=%s; plugin=%s', + 'client=%s; plugin=%s', InstalledVersions::getVersion('php-tuf/php-tuf'), InstalledVersions::getVersion('php-tuf/composer-integration'), ); diff --git a/src/TufValidatedComposerRepository.php b/src/TufValidatedComposerRepository.php index 3f5d2b3..babd1ee 100644 --- a/src/TufValidatedComposerRepository.php +++ b/src/TufValidatedComposerRepository.php @@ -10,6 +10,7 @@ use Composer\Repository\ComposerRepository; use Composer\Util\Http\Response; use Composer\Util\HttpDownloader; +use GuzzleHttp\Client; use GuzzleHttp\Psr7\Utils; use Tuf\Client\Repository; use Tuf\Exception\NotFoundException; @@ -53,12 +54,6 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, $url = rtrim($repoConfig['url'], '/'); if (!empty($repoConfig['tuf'])) { - // TUF metadata can optionally be loaded from a different place than the Composer package metadata. - $metadataUrl = $repoConfig['tuf']['metadata-url'] ?? "$url/metadata/"; - if (!str_ends_with($metadataUrl, '/')) { - $metadataUrl .= '/'; - } - $maxBytes = $repoConfig['tuf']['max-bytes'] ?? NULL; if (is_int($maxBytes)) { Repository::$maxBytes = $maxBytes; @@ -66,7 +61,11 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, // @todo: Write a custom implementation of FileStorage that stores repo keys to user's global composer cache? $storage = $this->initializeStorage($url, $config); - $loader = new Loader($httpDownloader, $storage, $metadataUrl); + + // TUF metadata can optionally be loaded from a different place than the Composer package metadata. + $metadataUrl = $repoConfig['tuf']['metadata-url'] ?? "$url/metadata/"; + $client = new Client(['base_uri' => $metadataUrl]); + $loader = new Loader($storage, $io, $client); $loader = new StaticCache($loader, $io, $metadataUrl); $loader = new SizeCheckingLoader($loader); $this->updater = new ComposerCompatibleUpdater($loader, $storage); diff --git a/tests/ComposerCommandsTest.php b/tests/ComposerCommandsTest.php index a9d3923..703359a 100644 --- a/tests/ComposerCommandsTest.php +++ b/tests/ComposerCommandsTest.php @@ -81,7 +81,7 @@ public function testRequireAndRemove(): void $this->assertStringContainsString("[TUF] Loading '1.package_metadata.json' from static cache.", $debug); $this->assertStringContainsString("[TUF] Loading '1.package.json' from static cache.", $debug); // ...which should preclude any "not modified" responses. - $this->assertStringNotContainsString('[304] http://localhost:8080/', $debug); + $this->assertStringNotContainsString('[TUF] 304:', $debug); // The metadata should actually be *downloaded* no more than twice -- once while the // dependency tree is being solved, and again when the solved dependencies are actually // downloaded (which is done by Composer effectively re-invoking itself, resulting in diff --git a/tests/LoaderTest.php b/tests/LoaderTest.php index c249173..9f46a7c 100644 --- a/tests/LoaderTest.php +++ b/tests/LoaderTest.php @@ -3,16 +3,17 @@ namespace Tuf\ComposerIntegration\Tests; use Composer\Config; -use Composer\Downloader\MaxFileSizeExceededException; -use Composer\Downloader\TransportException; -use Composer\Util\Http\Response; -use Composer\Util\HttpDownloader; +use Composer\IO\NullIO; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\RequestOptions; use DMS\PHPUnitExtensions\ArraySubset\Constraint\ArraySubset; use PHPUnit\Framework\TestCase; use Psr\Http\Message\StreamInterface; use Tuf\ComposerIntegration\ComposerFileStorage; use Tuf\ComposerIntegration\Loader; -use Tuf\Exception\DownloadSizeException; use Tuf\Exception\NotFoundException; /** @@ -20,114 +21,77 @@ */ class LoaderTest extends TestCase { - public function testLoader(): void + private readonly MockHandler $responses; + + /** + * {@inheritDoc} + */ + protected function setUp(): void { - $loader = function (HttpDownloader $downloader): Loader { - return new Loader( - $downloader, - $this->createMock(ComposerFileStorage::class), - '/metadata/' - ); - }; - - $url = '/metadata/root.json'; - $downloader = $this->createMock(HttpDownloader::class); - $downloader->expects($this->atLeastOnce()) - ->method('get') - ->with($url, $this->mockOptions(129)) - ->willReturn(new Response(['url' => $url], 200, [], '')); - $this->assertInstanceOf(StreamInterface::class, $loader($downloader)->load('root.json', 128)->wait()); - - // Any TransportException with a 404 error could should be converted - // into a NotFoundException. - $exception = new TransportException(); - $exception->setStatusCode(404); - $downloader = $this->createMock(HttpDownloader::class); - $downloader->expects($this->atLeastOnce()) - ->method('get') - ->with('/metadata/bogus.txt', $this->mockOptions(11)) - ->willThrowException($exception); + parent::setUp(); + $this->responses = new MockHandler(); + } + + private function getLoader(?ComposerFileStorage $storage = null): Loader + { + return new Loader( + $storage ?? $this->createMock(ComposerFileStorage::class), + new NullIO(), + new Client([ + 'handler' => HandlerStack::create($this->responses), + ]), + ); + } + + public function testBasicSuccessAndFailure(): void + { + $loader = $this->getLoader(); + + $this->responses->append(new Response()); + $this->assertInstanceOf(StreamInterface::class, $loader->load('root.json', 128)->wait()); + $this->assertRequestOptions(); + + // Any 404 response should raise a NotFoundException. + $this->responses->append(new Response(404)); try { - $loader($downloader)->load('bogus.txt', 10); + $loader->load('bogus.txt', 10)->wait(); $this->fail('Expected a NotFoundException, but none was thrown.'); } catch (NotFoundException $e) { $this->assertSame('Item not found: bogus.txt', $e->getMessage()); - } - - // A MaxFileSizeExceededException should be converted into a - // DownloadSizeException. - $downloader = $this->createMock(HttpDownloader::class); - $downloader->expects($this->atLeastOnce()) - ->method('get') - ->with('/metadata/too_big.txt', $this->mockOptions(11)) - ->willThrowException(new MaxFileSizeExceededException()); - try { - $loader($downloader)->load('too_big.txt', 10); - $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 = $this->createMock(HttpDownloader::class); - $downloader->expects($this->atLeastOnce()) - ->method('get') - ->with('/metadata/wtf.txt', $this->mockOptions(11)) - ->willThrowException($originalException); - try { - $loader($downloader)->load('wtf.txt', 10); - $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()); + $this->assertRequestOptions(); } } public function testNotModifiedResponse(): void { - $config = new Config(); - $storage = ComposerFileStorage::create('https://example.net/packages', $config); + $storage = ComposerFileStorage::create('https://example.net/packages', new Config()); $method = new \ReflectionMethod($storage, 'write'); - $method->setAccessible(true); $method->invoke($storage, 'test', 'Some test data.'); - $modifiedTime = $storage->getModifiedTime('test'); - - $downloader = $this->createMock(HttpDownloader::class); - $url = '2.test.json'; - $response = $this->createMock(Response::class); - $response->expects($this->atLeastOnce()) - ->method('getStatusCode') - ->willReturn(304); - $response->expects($this->never()) - ->method('getBody'); - $downloader->expects($this->atLeastOnce()) - ->method('get') - ->with($url, $this->mockOptions(1025, $modifiedTime)) - ->willReturn($response); - - $loader = new Loader($downloader, $storage); + + $this->responses->append(new Response(304)); + // Since the response has no actual body data, the fact that we get the contents // of the file we wrote here is proof that it was ultimately read from persistent // storage by the loader. - $this->assertSame('Some test data.', $loader->load('2.test.json', 1024)->wait()->getContents()); + $this->assertSame('Some test data.', $this->getLoader($storage)->load('2.test.json', 1024)->wait()->getContents()); + $this->assertRequestOptions($storage->getModifiedTime('test')); } - private function mockOptions(int $expectedSize, ?\DateTimeInterface $modifiedTime = null): object + private function assertRequestOptions(?\DateTimeInterface $modifiedTime = null): void { - $options = ['max_file_size' => $expectedSize]; + $options = $this->responses->getLastOptions(); + $this->assertIsCallable($options[RequestOptions::PROGRESS]); + + $request = $this->responses->getLastRequest(); // There's no real reason to expose versionHeader() to the world, so // it's okay to use reflection here. $method = new \ReflectionMethod(Loader::class, 'versionHeader'); - $options['http']['header'][] = $method->invoke(null); + $this->assertSame($request?->getHeaderLine('X-PHP-TUF'), $method->invoke(null)); if ($modifiedTime) { - $options['http']['header'][] = "If-Modified-Since: " . $modifiedTime->format('D, d M Y H:i:s') . ' GMT'; + $this->assertSame($request?->getHeaderLine('If-Modified-Since'), $modifiedTime->format('D, d M Y H:i:s') . ' GMT'); } - return new ArraySubset($options); } }