Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": [
Expand Down
79 changes: 39 additions & 40 deletions src/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,25 +21,29 @@
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,
) {}

/**
* {@inheritDoc}
*/
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');
Expand All @@ -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'),
);
Expand Down
13 changes: 6 additions & 7 deletions src/TufValidatedComposerRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,20 +54,18 @@ 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;
}

// @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);
Expand Down
2 changes: 1 addition & 1 deletion tests/ComposerCommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 52 additions & 88 deletions tests/LoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,131 +3,95 @@
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;

/**
* @covers \Tuf\ComposerIntegration\Loader
*/
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);
}
}