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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ composer.lock
.php-cs-fixer.cache
.phpunit.result.cache
/tests/fixtures/download
/tests/fixtures/var/
.idea/
50 changes: 50 additions & 0 deletions src/Model/TailwindBinaries.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the SymfonyCasts TailwindBundle package.
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfonycasts\TailwindBundle\Model;

class TailwindBinaries
{
/**
* @param TailwindBinary[] $assets
*/
public function __construct(
private readonly string $name,
private readonly string $publishedAt,
private readonly array $assets,
) {
}

public function getAssetByBinaryName(string $assetName): ?TailwindBinary
{
foreach ($this->assets as $asset) {
if ($asset->getName() === $assetName) {
return $asset;
}
}

return null;
}

public function getName(): string
{
return $this->name;
}

public function getPublishedAt(): string
{
return $this->publishedAt;
}

/** @return TailwindBinary[] */
public function getAssets(): array
{
return $this->assets;
}
}
57 changes: 57 additions & 0 deletions src/Model/TailwindBinary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/*
* This file is part of the SymfonyCasts TailwindBundle package.
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfonycasts\TailwindBundle\Model;

class TailwindBinary
{
public function __construct(
private readonly string $name,
private readonly string $contentType,
private readonly int $size,
private readonly string $digest,
private readonly string $createdAt,
private readonly string $downloadUrl,
) {
}

public function getName(): string
{
return $this->name;
}

public function getContentType(): string
{
return $this->contentType;
}

public function getSize(): int
{
return $this->size;
}

public function getDigest(): string
{
if (empty($this->digest)) {
return '';
}

return explode(':', $this->digest, 2)[1];
}

public function getCreatedAt(): string
{
return $this->createdAt;
}

public function getDownloadUrl(): string
{
return $this->downloadUrl;
}
}
85 changes: 77 additions & 8 deletions src/TailwindBinary.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
namespace Symfonycasts\TailwindBundle;

use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Process\Process;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfonycasts\TailwindBundle\Model\TailwindBinaries;
use Symfonycasts\TailwindBundle\Model\TailwindBinary as TailwindBinaryAsset;

/**
* Wraps and downloads the tailwindcss binary.
Expand Down Expand Up @@ -96,19 +99,73 @@ private function getBinaryPath(): string
return $this->binaryPath;
}

$this->binaryPath = $this->binaryDownloadDir.'/'.$this->getVersion().'/'.self::getBinaryName($this->getRawVersion(), $this->binaryPlatform);
$this->binaryPath = Path::canonicalize(
$this->binaryDownloadDir.'/'.$this->getVersion().'/'.self::getBinaryName(
$this->getRawVersion(),
$this->binaryPlatform
)
);

if (!is_file($this->binaryPath)) {
if (!is_file($this->binaryPath) || 0 === filesize($this->binaryPath)) {
if (is_file($this->binaryPath)) {
unlink($this->binaryPath);
}
$this->downloadExecutable();
}

return $this->binaryPath;
}

private function requestBinariesByVersion(string $version): TailwindBinaries
{
$url = \sprintf('https://api.github.com/repos/tailwindlabs/tailwindcss/releases/tags/v%s', $version);

$response = $this->httpClient->request('GET', $url);

$content = json_decode($response->getContent(), true, 512, \JSON_THROW_ON_ERROR);

$assets = [];
foreach ($content['assets'] as $asset) {
if ('text/plain' === $asset['content_type']) {
continue;
}

if (version_compare($version, '4.1.9', '<=')) {
$asset['digest'] = '';
}

$assets[] = new TailwindBinaryAsset(
name: $asset['name'],
contentType: $asset['content_type'],
size: $asset['size'],
digest: $asset['digest'],
createdAt: $asset['created_at'],
downloadUrl: $asset['browser_download_url'],
);
}

return new TailwindBinaries(
name: $content['name'],
publishedAt: $content['published_at'],
assets: $assets,
);
}

private function downloadExecutable(): void
{
$releases = $this->requestBinariesByVersion($this->getRawVersion());
$binaryName = self::getBinaryName($this->getRawVersion(), $this->binaryPlatform);
$url = \sprintf('https://github.com/tailwindlabs/tailwindcss/releases/download/%s/%s', $this->getVersion(), $binaryName);

$releaseToDownload = $releases->getAssetByBinaryName($binaryName);
if (null === $releaseToDownload) {
$availableAssets = implode(', ', array_map(
static fn (TailwindBinaryAsset $a) => $a->getName(),
$releases->getAssets()
));
throw new \RuntimeException(\sprintf('Could not find binary "%s" in the release. Available assets: %s', $binaryName, $availableAssets));
}

$url = $releaseToDownload->getDownloadUrl();

$this->output?->note(\sprintf('Downloading TailwindCSS binary from %s', $url));

Expand All @@ -120,28 +177,40 @@ private function downloadExecutable(): void
$progressBar = null;

$response = $this->httpClient->request('GET', $url, [
'on_progress' => function (int $dlNow, int $dlSize, array $info) use (&$progressBar): void {
// dlSize is not known at the start
'on_progress' => function (int $dlNow, int $dlSize, array $info) use (
$releaseToDownload,
&$progressBar
): void {
if (0 === $dlSize) {
return;
}

if (!$progressBar) {
$progressBar = $this->output?->createProgressBar($dlSize);
$progressBar = $this->output?->createProgressBar($releaseToDownload->getSize());
}

$progressBar?->setProgress($dlNow);
},
]);

$fileHandler = fopen($targetPath, 'w');
foreach ($this->httpClient->stream($response) as $chunk) {
fwrite($fileHandler, $chunk->getContent());
}
fclose($fileHandler);

$progressBar?->finish();
$this->output?->writeln('');
// make file executable
chmod($targetPath, 0777);

$digest = $releaseToDownload->getDigest();
if ('' !== $digest) {
$downloadedFileHash = hash_file('sha256', $targetPath);
if (!hash_equals($digest, $downloadedFileHash)) {
unlink($targetPath);
throw new \RuntimeException(\sprintf('Downloaded binary failed integrity check (expected hash: %s, actual hash: %s). The corrupt file has been removed. Please try again.', $digest, $downloadedFileHash));
}
}
}

/**
Expand Down Expand Up @@ -190,7 +259,7 @@ private static function getBinarySystem(string $version, string $platform): stri
$arch = $architectures[$machine] ?? null;

if (!$system || !$arch) {
throw new \Exception(\sprintf('Unknown platform or architecture (OS: %s, Machine: %s).', $os, $machine));
throw new \UnexpectedValueException(\sprintf('Unknown platform or architecture (OS: %s, Machine: %s).', $os, $machine));
}

// Detect MUSL only when version >= 4.0.0
Expand Down
73 changes: 66 additions & 7 deletions tests/TailwindBinaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Process\Process;
Expand All @@ -21,22 +22,24 @@ class TailwindBinaryTest extends TestCase
/**
* @dataProvider platformAndVersionProvider
*/
public function testBinaryIsDownloadedAndProcessCreated(string $version, string $platform, string $expectedBinaryName)
public function testBinaryIsDownloadedAndProcessCreated(string $version, string $platform, string $expectedBinaryName): void
{
$binaryDownloadDir = __DIR__.'/fixtures/download';
$binaryDownloadDir = Path::canonicalize(__DIR__.'/fixtures/download');

$fs = new Filesystem();
if (file_exists($binaryDownloadDir)) {
$fs->remove($binaryDownloadDir);
}
$fs->mkdir($binaryDownloadDir);

$client = new MockHttpClient([
new MockResponse((new Filesystem())->readFile(__DIR__.'/fixtures/MockGitHubResponse.json')),
new MockResponse('fake binary contents'),
]);

$binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, 'fake-version', null, $client, $platform);
$binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, $version, null, $client, $platform);
$process = $binary->createProcess(['-i', 'fake.css']);
$binaryFile = $binaryDownloadDir.'/fake-version/'.$expectedBinaryName;
$binaryFile = $binaryDownloadDir.'/'.$version.'/'.$expectedBinaryName;
$this->assertFileExists($binaryFile);

$this->assertSame(
Expand All @@ -48,15 +51,15 @@ public function testBinaryIsDownloadedAndProcessCreated(string $version, string
/**
* @dataProvider versionProvider
*/
public function testGetVersionFromBinary(string $version)
public function testGetVersionFromBinary(string $version): void
{
$binaryDownloadDir = __DIR__.'/fixtures/download';
$fs = new Filesystem();
if (file_exists($binaryDownloadDir)) {
$fs->remove($binaryDownloadDir);
}
$fs->mkdir($binaryDownloadDir);
$binaryFile = $binaryDownloadDir.'/'.$version.'/'.TailwindBinary::getBinaryName(ltrim($version, 'v'));
$binaryFile = Path::canonicalize($binaryDownloadDir.'/'.$version.'/'.TailwindBinary::getBinaryName(ltrim($version, 'v')));

$binary1 = new TailwindBinary($binaryDownloadDir, __DIR__, null, $version);

Expand All @@ -76,7 +79,7 @@ public static function versionProvider(): iterable
yield ['v4.0.7'];
}

public function testCustomBinaryUsed()
public function testCustomBinaryUsed(): void
{
$client = new MockHttpClient();

Expand All @@ -90,6 +93,62 @@ public function testCustomBinaryUsed()
);
}

public function testZeroByteFileIsReplacedOnRedownload(): void
{
$binaryDownloadDir = Path::canonicalize(__DIR__.'/fixtures/download');

$fs = new Filesystem();
if (file_exists($binaryDownloadDir)) {
$fs->remove($binaryDownloadDir);
}
$fs->mkdir($binaryDownloadDir.'/v4.0.0');

// Place a 0-byte file to simulate a corrupted/interrupted download
// Use v4.0.0 (<=4.1.9) so no digest is available and the integrity check is skipped
$binaryName = 'tailwindcss-linux-x64';
$corruptFile = $binaryDownloadDir.'/v4.0.0/'.$binaryName;
file_put_contents($corruptFile, '');
$this->assertSame(0, filesize($corruptFile));

$client = new MockHttpClient([
new MockResponse((new Filesystem())->readFile(__DIR__.'/fixtures/MockGitHubResponse.json')),
new MockResponse('fake binary contents'),
]);

$binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, 'v4.0.0', null, $client, 'linux-x64');
$binary->createProcess(['-i', 'fake.css']);

$this->assertFileExists($corruptFile);
$this->assertGreaterThan(0, filesize($corruptFile));
}

public function testIntegrityFailureDeletesFileAndThrows(): void
{
$binaryDownloadDir = Path::canonicalize(__DIR__.'/fixtures/download');

$fs = new Filesystem();
if (file_exists($binaryDownloadDir)) {
$fs->remove($binaryDownloadDir);
}
$fs->mkdir($binaryDownloadDir);

// Return valid API response (with a real digest) but wrong binary content so hash won't match
$client = new MockHttpClient([
new MockResponse((new Filesystem())->readFile(__DIR__.'/fixtures/MockGitHubResponse.json')),
new MockResponse('this content does not match the sha256 in the fixture'),
]);

$binary = new TailwindBinary($binaryDownloadDir, __DIR__, null, 'v4.1.16', null, $client, 'linux-x64');

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/integrity check/');
$binary->createProcess(['-i', 'fake.css']);

// The corrupt file must have been removed
$binaryFile = $binaryDownloadDir.'/v4.1.16/tailwindcss-linux-x64';
$this->assertFileDoesNotExist($binaryFile);
}

public function platformAndVersionProvider(): iterable
{
yield ['3.4.17', 'linux-arm64', 'tailwindcss-linux-arm64'];
Expand Down
Loading
Loading