Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

For better performance, Loader should send If-Modified-Since headers when downloading TUF metadata, and read unmodified data from persistent storage #87

Merged
merged 8 commits into from
Oct 25, 2023
36 changes: 36 additions & 0 deletions src/ComposerFileStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,40 @@ public static function create(string $url, Config $config): self
]);
return new static($basePath);
}

/**
* Returns the time a stored file was last modified.
*
* @param string $name
* The name of the file to check, without its `.json` extension.
*
* @return \DateTimeImmutable|null
* The time the file was last modified, or null if the file doesn't exist.
*
* @throws \RuntimeException
* If the file exists but its modification time could not be determined.
*/
public function getModifiedTime(string $name): ?\DateTimeImmutable
{
$path = $this->toPath($name);
if (file_exists($path)) {
$modifiedTime = filemtime($path);
if (is_int($modifiedTime)) {
// The @ prefix tells \DateTimeImmutable that $modifiedTime is
// a UNIX timestamp.
return new \DateTimeImmutable("@$modifiedTime");
}
throw new \RuntimeException("Could not get the modification time for '$path'.");
}
return null;
}

/**
* {@inheritDoc}
*/
public function read(string $name): ?string
{
return parent::read($name);
}

}
28 changes: 24 additions & 4 deletions src/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/
class Loader implements LoaderInterface
{
public function __construct(private HttpDownloader $downloader, private string $baseUrl = '')
public function __construct(private HttpDownloader $downloader, private ComposerFileStorage $storage, private string $baseUrl = '')
{
}

Expand All @@ -27,11 +27,31 @@ public function load(string $locator, int $maxBytes): StreamInterface
{
$url = $this->baseUrl . $locator;

try {
$options = [
// 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();
'max_file_size' => $maxBytes + 1,
];

// The name of the file in persistent storage will differ from $locator.
$name = basename($locator, '.json');
$name = ltrim($name, '.0123456789');

$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';
}

try {
$response = $this->downloader->get($url, $options);
// 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 = $this->storage->read($name);
} else {
$content = $response->getBody();
}
return Utils::streamFor($content);
} catch (TransportException $e) {
if ($e->getStatusCode() === 404) {
Expand Down
15 changes: 7 additions & 8 deletions src/TufValidatedComposerRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use Tuf\Exception\NotFoundException;
use Tuf\Loader\SizeCheckingLoader;
use Tuf\Metadata\RootMetadata;
use Tuf\Metadata\StorageInterface;

/**
* Defines a Composer repository that is protected by TUF.
Expand Down Expand Up @@ -65,11 +64,11 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config,
Repository::$maxBytes = $maxBytes;
}

$this->updater = new ComposerCompatibleUpdater(
new SizeCheckingLoader(new Loader($httpDownloader, $metadataUrl)),
// @todo: Write a custom implementation of FileStorage that stores repo keys to user's global composer cache?
$this->initializeStorage($url, $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);
$loader = new SizeCheckingLoader($loader);
$this->updater = new ComposerCompatibleUpdater($loader, $storage);

$io->debug("[TUF] Packages from $url are verified by TUF.");
$io->debug("[TUF] Metadata source: $metadataUrl");
Expand All @@ -88,13 +87,13 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config,
* @param Config $config
* The Composer configuration.
*
* @return \Tuf\Metadata\StorageInterface
* @return \Tuf\ComposerIntegration\ComposerFileStorage
* A durable storage object for this repository's TUF data.
*
* @throws \RuntimeException
* If no root metadata could be found for this repository.
*/
private function initializeStorage(string $url, Config $config): StorageInterface
private function initializeStorage(string $url, Config $config): ComposerFileStorage
{
$storage = ComposerFileStorage::create($url, $config);

Expand Down
31 changes: 31 additions & 0 deletions tests/ComposerFileStorageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,35 @@ public function testCreate(): void
ComposerFileStorage::create('https://example.net/packages', $config);
$this->assertDirectoryExists($basePath);
}

/**
* @covers ::getModifiedTime
*/
public function testModifiedTime(): void
{
$config = new Config();

$basePath = implode(DIRECTORY_SEPARATOR, [
ComposerFileStorage::basePath($config),
'https---example.net-packages',
]);
$storage = ComposerFileStorage::create('https://example.net/packages', $config);

// A non-existent file should produce a null modification time.
$this->assertNull($storage->getModifiedTime('test'));

// Once the file exists, we should get a modification time.
$path = $basePath . DIRECTORY_SEPARATOR . 'test.json';
touch($path);
$modifiedTime = $storage->getModifiedTime('test');
$this->assertInstanceOf(\DateTimeImmutable::class, $modifiedTime);

// Change the modification time, and clear the file stat cache so we can
// be sure the new modification time is picked up; it seems that touch()
// doesn't do that automatically.
$newModifiedTime = $modifiedTime->getTimestamp() + 10;
touch($path, $newModifiedTime);
clearstatcache(filename: $path);
$this->assertSame($storage->getModifiedTime('test')->getTimestamp(), $newModifiedTime);
}
}
42 changes: 38 additions & 4 deletions tests/LoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

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 GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Http\Message\StreamInterface;
use Tuf\ComposerIntegration\ComposerFileStorage;
use Tuf\ComposerIntegration\Loader;
use Tuf\Exception\DownloadSizeException;
use Tuf\Exception\RepoFileNotFound;
Expand All @@ -23,10 +25,12 @@ class LoaderTest extends TestCase
public function testLoader(): void
{
$downloader = $this->prophesize(HttpDownloader::class);
$loader = new Loader($downloader->reveal(), '/metadata/');
$storage = $this->prophesize(ComposerFileStorage::class);
$loader = new Loader($downloader->reveal(), $storage->reveal(), '/metadata/');

$downloader->get('/metadata/root.json', ['max_file_size' => 129])
->willReturn(new Response())
$url = '/metadata/root.json';
$downloader->get($url, ['max_file_size' => 129])
->willReturn(new Response(['url' => $url], 200, [], null))
->shouldBeCalled();
$this->assertInstanceOf(StreamInterface::class, $loader->load('root.json', 128));

Expand Down Expand Up @@ -71,4 +75,34 @@ public function testLoader(): void
$this->assertSame($originalException, $e->getPrevious());
}
}

public function testNotModifiedResponse(): void
{
$config = new Config();
$storage = ComposerFileStorage::create('https://example.net/packages', $config);

$method = new \ReflectionMethod($storage, 'write');
$method->setAccessible(true);
$method->invoke($storage, 'test', 'Some test data.');
$modifiedTime = $storage->getModifiedTime('test')->format('D, d M Y H:i:s');

$downloader = $this->prophesize(HttpDownloader::class);
$options = [
'max_file_size' => 1025,
'http' => [
'header' => [
"If-Modified-Since: $modifiedTime GMT",
],
],
];
$url = '2.test.json';
$response = new Response(['url' => $url], 304, [], null);
$downloader->get($url, $options)->willReturn($response)->shouldBeCalled();

$loader = new Loader($downloader->reveal(), $storage);
// 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)->getContents());
}
}