diff --git a/src/ComposerFileStorage.php b/src/ComposerFileStorage.php index 41602c8..44c2bb8 100644 --- a/src/ComposerFileStorage.php +++ b/src/ComposerFileStorage.php @@ -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); + } + } diff --git a/src/Loader.php b/src/Loader.php index ca20e98..6364ab1 100644 --- a/src/Loader.php +++ b/src/Loader.php @@ -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 = '') { } @@ -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) { diff --git a/src/TufValidatedComposerRepository.php b/src/TufValidatedComposerRepository.php index ad29c67..ac33965 100644 --- a/src/TufValidatedComposerRepository.php +++ b/src/TufValidatedComposerRepository.php @@ -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. @@ -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"); @@ -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); diff --git a/tests/ComposerFileStorageTest.php b/tests/ComposerFileStorageTest.php index b1208d1..43a99ca 100644 --- a/tests/ComposerFileStorageTest.php +++ b/tests/ComposerFileStorageTest.php @@ -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); + } } diff --git a/tests/LoaderTest.php b/tests/LoaderTest.php index 406485e..b0c2836 100644 --- a/tests/LoaderTest.php +++ b/tests/LoaderTest.php @@ -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; @@ -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)); @@ -71,4 +75,38 @@ 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 = $this->prophesize(Response::class); + $response->getStatusCode()->willReturn(304)->shouldBeCalled(); + $response->getBody()->shouldNotBeCalled(); + $downloader->get($url, $options) + ->willReturn($response->reveal()) + ->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()); + } }