From 8b572e1b316b17120fd8ba8c2163b5fe6b797933 Mon Sep 17 00:00:00 2001 From: Robin C <7423905+Taminoful@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:00:18 +0100 Subject: [PATCH 1/4] Fix git file exclusions Removes the possibility that the .idea folder or leftover test files (binaries) get commited to the project. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e0bad38..d1094e6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ composer.lock .php-cs-fixer.cache .phpunit.result.cache /tests/fixtures/download +/tests/fixtures/var/ +.idea/ From 57f27d8fb918565c1fba62401ffbaaf09f47737e Mon Sep 17 00:00:00 2001 From: Robin C <7423905+Taminoful@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:09:56 +0100 Subject: [PATCH 2/4] Add Models for holding data more elegantly Adds some model classes that represent the important parts of the GitHub tag endpoint data that makes it easier to rework the download process. Also allows for easy extension if more fields should become relevant in the future. `TailwindBinaries` represents a release of a version which holds the downloadable assets. The class also contains a helper function as part of it's model too which allows to search for assets of a release by their tag name. `TailwindBinary` represents the details of each executable that is pushed to GitHub as part of a release. Important to note is, that the digest field only gets filled after Tailwind v4.1.9 but gets filled after, since, realistically people will use v4 from now on more than v3, I decided to not make the field nullable or go the extra route of comparing against the contents of the `sha256sums.txt`. This approach should keep the code more clean going forward as each binary has their digest attached directly as a field. --- src/Model/TailwindBinaries.php | 63 ++++++++++++++++++++++++ src/Model/TailwindBinary.php | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/Model/TailwindBinaries.php create mode 100644 src/Model/TailwindBinary.php diff --git a/src/Model/TailwindBinaries.php b/src/Model/TailwindBinaries.php new file mode 100644 index 0000000..94f5d8b --- /dev/null +++ b/src/Model/TailwindBinaries.php @@ -0,0 +1,63 @@ + + * 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 +{ + public function __construct( + private string $name, + private string $publishedAt, + private array $assets, + ) { + } + + public function getAssetByBinaryName(string $assetName) + { + foreach ($this->assets as $asset) { + if ($asset->getName() !== $assetName) { + continue; + } + + return $asset; + } + + return null; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getPublishedAt(): string + { + return $this->publishedAt; + } + + public function setPublishedAt(string $publishedAt): void + { + $this->publishedAt = $publishedAt; + } + + public function getAssets(): array + { + return $this->assets; + } + + public function setAssets(array $assets): void + { + $this->assets = $assets; + } +} diff --git a/src/Model/TailwindBinary.php b/src/Model/TailwindBinary.php new file mode 100644 index 0000000..4b89339 --- /dev/null +++ b/src/Model/TailwindBinary.php @@ -0,0 +1,87 @@ + + * 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 string $name, + private string $contentType, + private int $fileSize, + private string $digest, + private string $createdAt, + private string $downloadUrl, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function setContentType(string $contentType): void + { + $this->contentType = $contentType; + } + + public function getFileSize(): int + { + return $this->fileSize; + } + + public function setFileSize(int $fileSize): void + { + $this->fileSize = $fileSize; + } + + public function getDigest(): string + { + if (empty($this->digest)) { + return ''; + } + + return explode(':', $this->digest, 2)[1]; + } + + public function setDigest(string $digest): void + { + $this->digest = $digest; + } + + public function getCreatedAt(): string + { + return $this->createdAt; + } + + public function setCreatedAt(string $createdAt): void + { + $this->createdAt = $createdAt; + } + + public function getDownloadUrl(): string + { + return $this->downloadUrl; + } + + public function setDownloadUrl(string $downloadUrl): void + { + $this->downloadUrl = $downloadUrl; + } +} From 962b30b1e9ce9375a39112638f70e8a856281625 Mon Sep 17 00:00:00 2001 From: Robin C <7423905+Taminoful@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:40:57 +0100 Subject: [PATCH 3/4] Change rework of the download method This commit changes the download method to use the GitHub API endpoint instead. It's requesting the information of the API about the tag and temporarily saves it in the `Model` classes for further use. From there the actual file gets downloaded over the API provided link. The commit also starts using the `Path::canonicalize()` method to eliminate any potential pathing issues. This does have the downside of sending two requests instead of one but allows for a more robust download process. E.g., it's now possible to check the file integrity with the provided SHA256 hash to determine if the file got corrupted as described in SymfonyCasts#115. In the future this change also allows for removing any hardcoded lists within the code that contain the platform executable names, as it's possible to just get the list off GitHub, which helps in maintaining if TailwindLabs decides to build for other platforms or removes platforms from their builds in the future. The main functionality for this lives in `requestBinariesByVersion()` which I might move to the `TailwindBinary` Model during cleanup, depending on where it feels right. --- src/TailwindBinary.php | 120 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/src/TailwindBinary.php b/src/TailwindBinary.php index 3ca2fc7..e1c612b 100644 --- a/src/TailwindBinary.php +++ b/src/TailwindBinary.php @@ -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. @@ -96,7 +99,12 @@ 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)) { $this->downloadExecutable(); @@ -105,10 +113,116 @@ private function getBinaryPath(): string return $this->binaryPath; } + private function requestBinariesByVersion(string $version): TailwindBinaries + { + $url = \sprintf('https://api.github.com/repos/tailwindlabs/tailwindcss/releases/tags/v%s', $version); + $assets = []; + + $response = $this->httpClient->request('GET', $url); + + $content = json_decode($response->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + $tailwindBinaries = new TailwindBinaries( + name: $content['name'], + publishedAt: $content['published_at'], + assets: $assets + ); + + foreach ($content['assets'] as $asset) { + if ('text/plain' === $asset['content_type']) { + continue; + } + + if (version_compare($version, '4.1.9', '<=')) { + $this->output?->note('No digest available for version below 4.1.9. No file integrity check possible.'); + $asset['digest'] = ''; + } + + $asset = new TailwindBinaryAsset( + name: $asset['name'], + contentType: $asset['content_type'], + fileSize: $asset['size'], + digest: $asset['digest'], + createdAt: $asset['created_at'], + downloadUrl: $asset['browser_download_url'], + ); + + $assets[] = $asset; + } + + $tailwindBinaries->setAssets($assets); + + return $tailwindBinaries; + } + private function downloadExecutable(): void + { + $releases = $this->requestBinariesByVersion($this->getRawVersion()); + $binaryName = self::getBinaryName($this->getRawVersion(), $this->binaryPlatform); + + $releaseToDownload = $releases->getAssetByBinaryName($binaryName); + $url = $releaseToDownload->getDownloadUrl(); + + $this->output?->note(\sprintf('Downloading TailwindCSS binary from %s', $url)); + + if (!is_dir($this->binaryDownloadDir.'/'.$this->getVersion())) { + mkdir($this->binaryDownloadDir.'/'.$this->getVersion(), 0777, true); + } + + $targetPath = $this->binaryDownloadDir.'/'.$this->getVersion().'/'.$binaryName; + $progressBar = null; + + $response = $this->httpClient->request('GET', $url, [ + 'on_progress' => function (int $dlNow, int $dlSize, array $info) use ( + $releaseToDownload, + &$progressBar + ): void { + if (0 === $dlSize) { + return; + } + + if (!$progressBar) { + $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(''); + chmod($targetPath, 0777); + + $downloadedFileHash = hash_file('sha256', $targetPath); + + if (!$this->checkFileIntegrity($releaseToDownload->getDigest(), $downloadedFileHash)) { + $this->output?->error( + 'There has been an error downloading the file. It may have become corrupted during download. See troubleshooting in documentation.' + ); + $this->output?->error(\sprintf('Expected file hash %s', $releaseToDownload->getDigest())); + $this->output?->error(\sprintf('Expected file hash %s', $downloadedFileHash)); + } + } + + private function checkFileIntegrity(string $digest, string $fileHash): bool + { + return hash_equals($digest, $fileHash); + } + + private function downloadExecutableOld(): void { $binaryName = self::getBinaryName($this->getRawVersion(), $this->binaryPlatform); - $url = \sprintf('https://github.com/tailwindlabs/tailwindcss/releases/download/%s/%s', $this->getVersion(), $binaryName); + $url = \sprintf( + 'https://github.com/tailwindlabs/tailwindcss/releases/download/%s/%s', + $this->getVersion(), + $binaryName + ); $this->output?->note(\sprintf('Downloading TailwindCSS binary from %s', $url)); @@ -190,7 +304,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 From 813b97d9ff73f304e4c209f3ce524c46b0e9ff11 Mon Sep 17 00:00:00 2001 From: Robin C <7423905+Taminoful@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:17:22 +0200 Subject: [PATCH 4/4] Fix file integrity validation for downloaded binaries (issue #115) - Detect and delete 0-byte files before re-downloading, fixing the core Windows antivirus interference bug where a corrupt file blocked recovery - Validate SHA256 digest after download; delete file and throw a clear RuntimeException on mismatch so the next run triggers a clean retry - Skip integrity check for versions <=4.1.9 where no digest is available - Replace dd() debug call with a RuntimeException listing available assets - Fix double "Expected file hash" label (second was the actual hash) - Fix awkward TailwindBinaries construction (build assets array first, pass to constructor directly, removing the setAssets() workaround) - Simplify model classes: make properties readonly, remove unused setters, rename getFileSize() to getSize(), add return type to getAssetByBinaryName() - Remove dead downloadExecutableOld() method - Add tailwindcss-linux-armv7 to mock fixture so armv7 test case resolves - Add tests for 0-byte re-download and integrity failure scenarios --- src/Model/TailwindBinaries.php | 33 +-- src/Model/TailwindBinary.php | 46 +-- src/TailwindBinary.php | 99 ++----- tests/TailwindBinaryTest.php | 73 ++++- tests/TailwindBuilderTest.php | 5 +- tests/fixtures/MockGitHubResponse.json | 379 +++++++++++++++++++++++++ 6 files changed, 493 insertions(+), 142 deletions(-) create mode 100644 tests/fixtures/MockGitHubResponse.json diff --git a/src/Model/TailwindBinaries.php b/src/Model/TailwindBinaries.php index 94f5d8b..7d0cb7c 100644 --- a/src/Model/TailwindBinaries.php +++ b/src/Model/TailwindBinaries.php @@ -11,21 +11,22 @@ class TailwindBinaries { + /** + * @param TailwindBinary[] $assets + */ public function __construct( - private string $name, - private string $publishedAt, - private array $assets, + private readonly string $name, + private readonly string $publishedAt, + private readonly array $assets, ) { } - public function getAssetByBinaryName(string $assetName) + public function getAssetByBinaryName(string $assetName): ?TailwindBinary { foreach ($this->assets as $asset) { - if ($asset->getName() !== $assetName) { - continue; + if ($asset->getName() === $assetName) { + return $asset; } - - return $asset; } return null; @@ -36,28 +37,14 @@ public function getName(): string return $this->name; } - public function setName(string $name): void - { - $this->name = $name; - } - public function getPublishedAt(): string { return $this->publishedAt; } - public function setPublishedAt(string $publishedAt): void - { - $this->publishedAt = $publishedAt; - } - + /** @return TailwindBinary[] */ public function getAssets(): array { return $this->assets; } - - public function setAssets(array $assets): void - { - $this->assets = $assets; - } } diff --git a/src/Model/TailwindBinary.php b/src/Model/TailwindBinary.php index 4b89339..5e13592 100644 --- a/src/Model/TailwindBinary.php +++ b/src/Model/TailwindBinary.php @@ -12,12 +12,12 @@ class TailwindBinary { public function __construct( - private string $name, - private string $contentType, - private int $fileSize, - private string $digest, - private string $createdAt, - private string $downloadUrl, + private readonly string $name, + private readonly string $contentType, + private readonly int $size, + private readonly string $digest, + private readonly string $createdAt, + private readonly string $downloadUrl, ) { } @@ -26,29 +26,14 @@ public function getName(): string return $this->name; } - public function setName(string $name): void - { - $this->name = $name; - } - public function getContentType(): string { return $this->contentType; } - public function setContentType(string $contentType): void - { - $this->contentType = $contentType; - } - - public function getFileSize(): int - { - return $this->fileSize; - } - - public function setFileSize(int $fileSize): void + public function getSize(): int { - $this->fileSize = $fileSize; + return $this->size; } public function getDigest(): string @@ -60,28 +45,13 @@ public function getDigest(): string return explode(':', $this->digest, 2)[1]; } - public function setDigest(string $digest): void - { - $this->digest = $digest; - } - public function getCreatedAt(): string { return $this->createdAt; } - public function setCreatedAt(string $createdAt): void - { - $this->createdAt = $createdAt; - } - public function getDownloadUrl(): string { return $this->downloadUrl; } - - public function setDownloadUrl(string $downloadUrl): void - { - $this->downloadUrl = $downloadUrl; - } } diff --git a/src/TailwindBinary.php b/src/TailwindBinary.php index e1c612b..cced545 100644 --- a/src/TailwindBinary.php +++ b/src/TailwindBinary.php @@ -106,7 +106,10 @@ private function getBinaryPath(): string ) ); - if (!is_file($this->binaryPath)) { + if (!is_file($this->binaryPath) || 0 === filesize($this->binaryPath)) { + if (is_file($this->binaryPath)) { + unlink($this->binaryPath); + } $this->downloadExecutable(); } @@ -116,43 +119,36 @@ private function getBinaryPath(): string private function requestBinariesByVersion(string $version): TailwindBinaries { $url = \sprintf('https://api.github.com/repos/tailwindlabs/tailwindcss/releases/tags/v%s', $version); - $assets = []; $response = $this->httpClient->request('GET', $url); $content = json_decode($response->getContent(), true, 512, \JSON_THROW_ON_ERROR); - $tailwindBinaries = new TailwindBinaries( - name: $content['name'], - publishedAt: $content['published_at'], - assets: $assets - ); - + $assets = []; foreach ($content['assets'] as $asset) { if ('text/plain' === $asset['content_type']) { continue; } if (version_compare($version, '4.1.9', '<=')) { - $this->output?->note('No digest available for version below 4.1.9. No file integrity check possible.'); $asset['digest'] = ''; } - $asset = new TailwindBinaryAsset( + $assets[] = new TailwindBinaryAsset( name: $asset['name'], contentType: $asset['content_type'], - fileSize: $asset['size'], + size: $asset['size'], digest: $asset['digest'], createdAt: $asset['created_at'], downloadUrl: $asset['browser_download_url'], ); - - $assets[] = $asset; } - $tailwindBinaries->setAssets($assets); - - return $tailwindBinaries; + return new TailwindBinaries( + name: $content['name'], + publishedAt: $content['published_at'], + assets: $assets, + ); } private function downloadExecutable(): void @@ -161,6 +157,14 @@ private function downloadExecutable(): void $binaryName = self::getBinaryName($this->getRawVersion(), $this->binaryPlatform); $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)); @@ -199,63 +203,14 @@ private function downloadExecutable(): void $this->output?->writeln(''); chmod($targetPath, 0777); - $downloadedFileHash = hash_file('sha256', $targetPath); - - if (!$this->checkFileIntegrity($releaseToDownload->getDigest(), $downloadedFileHash)) { - $this->output?->error( - 'There has been an error downloading the file. It may have become corrupted during download. See troubleshooting in documentation.' - ); - $this->output?->error(\sprintf('Expected file hash %s', $releaseToDownload->getDigest())); - $this->output?->error(\sprintf('Expected file hash %s', $downloadedFileHash)); - } - } - - private function checkFileIntegrity(string $digest, string $fileHash): bool - { - return hash_equals($digest, $fileHash); - } - - private function downloadExecutableOld(): void - { - $binaryName = self::getBinaryName($this->getRawVersion(), $this->binaryPlatform); - $url = \sprintf( - 'https://github.com/tailwindlabs/tailwindcss/releases/download/%s/%s', - $this->getVersion(), - $binaryName - ); - - $this->output?->note(\sprintf('Downloading TailwindCSS binary from %s', $url)); - - if (!is_dir($this->binaryDownloadDir.'/'.$this->getVersion())) { - mkdir($this->binaryDownloadDir.'/'.$this->getVersion(), 0777, true); - } - - $targetPath = $this->binaryDownloadDir.'/'.$this->getVersion().'/'.$binaryName; - $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 - if (0 === $dlSize) { - return; - } - - if (!$progressBar) { - $progressBar = $this->output?->createProgressBar($dlSize); - } - - $progressBar?->setProgress($dlNow); - }, - ]); - $fileHandler = fopen($targetPath, 'w'); - foreach ($this->httpClient->stream($response) as $chunk) { - fwrite($fileHandler, $chunk->getContent()); + $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)); + } } - fclose($fileHandler); - $progressBar?->finish(); - $this->output?->writeln(''); - // make file executable - chmod($targetPath, 0777); } /** diff --git a/tests/TailwindBinaryTest.php b/tests/TailwindBinaryTest.php index f5bda58..d585105 100644 --- a/tests/TailwindBinaryTest.php +++ b/tests/TailwindBinaryTest.php @@ -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; @@ -21,9 +22,10 @@ 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); @@ -31,12 +33,13 @@ public function testBinaryIsDownloadedAndProcessCreated(string $version, string $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( @@ -48,7 +51,7 @@ 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(); @@ -56,7 +59,7 @@ public function testGetVersionFromBinary(string $version) $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); @@ -76,7 +79,7 @@ public static function versionProvider(): iterable yield ['v4.0.7']; } - public function testCustomBinaryUsed() + public function testCustomBinaryUsed(): void { $client = new MockHttpClient(); @@ -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']; diff --git a/tests/TailwindBuilderTest.php b/tests/TailwindBuilderTest.php index 92eea42..ee433b5 100644 --- a/tests/TailwindBuilderTest.php +++ b/tests/TailwindBuilderTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; use Symfonycasts\TailwindBundle\TailwindBuilder; class TailwindBuilderTest extends TestCase @@ -19,7 +20,7 @@ class TailwindBuilderTest extends TestCase protected function setUp(): void { $fs = new Filesystem(); - $fs->mkdir(__DIR__.'/fixtures/var/tailwind'); + $fs->mkdir(Path::canonicalize(__DIR__.'/fixtures/var/tailwind')); } protected function tearDown(): void @@ -30,7 +31,7 @@ protected function tearDown(): void // so try to clean up the dir a few times while (true) { try { - $fs->remove(__DIR__.'/fixtures/var/tailwind'); + $fs->remove(Path::canonicalize(__DIR__.'/fixtures/var/tailwind')); break; } catch (IOException $e) { if ($i++ > 5) { diff --git a/tests/fixtures/MockGitHubResponse.json b/tests/fixtures/MockGitHubResponse.json new file mode 100644 index 0000000..975fc69 --- /dev/null +++ b/tests/fixtures/MockGitHubResponse.json @@ -0,0 +1,379 @@ +{ + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/256642784", + "assets_url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/256642784/assets", + "upload_url": "https://uploads.github.com/repos/tailwindlabs/tailwindcss/releases/256642784/assets{?name,label}", + "html_url": "https://github.com/tailwindlabs/tailwindcss/releases/tag/v4.1.16", + "id": 256642784, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOBlGyP84PTA7g", + "tag_name": "v4.1.16", + "target_commitish": "main", + "name": "v4.1.16", + "draft": false, + "immutable": false, + "prerelease": false, + "created_at": "2025-10-23T10:32:27Z", + "updated_at": "2025-10-23T10:55:20Z", + "published_at": "2025-10-23T10:55:20Z", + "assets": [ + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718831", + "id": 307718831, + "node_id": "RA_kwDOBlGyP84SV2qv", + "name": "sha256sums.txt", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "text/plain", + "state": "uploaded", + "size": 652, + "digest": "sha256:962687e148bb03a6f0927b80b0824f78bb876b7b6c2584cef19369871d678b60", + "download_count": 167, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:19Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/sha256sums.txt" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718831", + "id": 307718831, + "node_id": "RA_kwDOBlGyP84SV2qv", + "name": "tailwindcss-linux-armv7", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 98765432, + "digest": "sha256:aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899", + "download_count": 12, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:25Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-armv7" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718832", + "id": 307718832, + "node_id": "RA_kwDOBlGyP84SV2qw", + "name": "tailwindcss-linux-arm64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 113107514, + "digest": "sha256:967eb434f4d6a1c0dfda106decc646cb742e04d745aa484726023cdd46ba8eda", + "download_count": 302, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:25Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-arm64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718833", + "id": 307718833, + "node_id": "RA_kwDOBlGyP84SV2qx", + "name": "tailwindcss-linux-arm64-musl", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 108432995, + "digest": "sha256:bd1947c4ee8126333db2ae87fd19232aaca9286f930e4d50fa5e0c6d56c16036", + "download_count": 190, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:24Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-arm64-musl" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718836", + "id": 307718836, + "node_id": "RA_kwDOBlGyP84SV2q0", + "name": "tailwindcss-linux-x64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 122445527, + "digest": "sha256:09e6876a63ceb09ccd7e5867e3dbb2b2dc65c3a2f2e2fe210d68ea3bc0432050", + "download_count": 5284, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:26Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-x64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718837", + "id": 307718837, + "node_id": "RA_kwDOBlGyP84SV2q1", + "name": "tailwindcss-linux-x64-musl", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 115048104, + "digest": "sha256:02ffe87b7e37da4c661156862743fd65e71ec379f32fccf9588faf33d8cb12cb", + "download_count": 735, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:25Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-x64-musl" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718834", + "id": 307718834, + "node_id": "RA_kwDOBlGyP84SV2qy", + "name": "tailwindcss-macos-arm64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 76693168, + "digest": "sha256:e6cd44b8167f5746ca32e54f6a1411dd1a6c0dd15d26a9c273b3b2ed9d87df7d", + "download_count": 787, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:23Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-macos-arm64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718835", + "id": 307718835, + "node_id": "RA_kwDOBlGyP84SV2qz", + "name": "tailwindcss-macos-x64", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/octet-stream", + "state": "uploaded", + "size": 81069184, + "digest": "sha256:fde2aed09bf249cab9f986fd6f2089e3ce9a9ce1c7862fddbfa807c417e0f5d3", + "download_count": 279, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:23Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-macos-x64" + }, + { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/assets/307718838", + "id": 307718838, + "node_id": "RA_kwDOBlGyP84SV2q2", + "name": "tailwindcss-windows-x64.exe", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/x-msdos-program", + "state": "uploaded", + "size": 133820928, + "digest": "sha256:07d351195199d9b3334cfdf7bbce39dd91abc2fb319041c78b7405bc3f850daf", + "download_count": 1969, + "created_at": "2025-10-23T10:42:18Z", + "updated_at": "2025-10-23T10:42:27Z", + "browser_download_url": "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-windows-x64.exe" + } + ], + "tarball_url": "https://api.github.com/repos/tailwindlabs/tailwindcss/tarball/v4.1.16", + "zipball_url": "https://api.github.com/repos/tailwindlabs/tailwindcss/zipball/v4.1.16", + "body": "### Fixed\r\n\r\n- Discard candidates with an empty data type ([#19172](https://github.com/tailwindlabs/tailwindcss/pull/19172))\r\n- Fix canonicalization of arbitrary variants with attribute selectors ([#19176](https://github.com/tailwindlabs/tailwindcss/pull/19176))\r\n- Fix invalid colors due to nested `&` ([#19184](https://github.com/tailwindlabs/tailwindcss/pull/19184))\r\n- Improve canonicalization for `& > :pseudo` and `& :pseudo` arbitrary variants ([#19178](https://github.com/tailwindlabs/tailwindcss/pull/19178))\r\n", + "reactions": { + "url": "https://api.github.com/repos/tailwindlabs/tailwindcss/releases/256642784/reactions", + "total_count": 44, + "+1": 19, + "-1": 0, + "laugh": 0, + "hooray": 6, + "confused": 0, + "heart": 12, + "rocket": 5, + "eyes": 2 + } +} \ No newline at end of file