Skip to content

Fix Windows 0-byte Tailwind binary downloads & integrity checks - #133

Merged
kbond merged 5 commits into
SymfonyCasts:1.xfrom
Taminoful:main
Jul 23, 2026
Merged

Fix Windows 0-byte Tailwind binary downloads & integrity checks#133
kbond merged 5 commits into
SymfonyCasts:1.xfrom
Taminoful:main

Conversation

@Taminoful

Copy link
Copy Markdown
Contributor

This PR fixes an issue on Windows where TailwindCSS binaries could be downloaded as empty (0-byte) executables due to antivirus interference, leading to SmartScreen errors and failed command execution. A detailed description of the issues occuring can be found in #115.

In some environments (notably with Windows Defender or third-party antivirus), the downloaded executable may be blocked or quarantined during download. This results in a 0-byte file being saved, which the bundle incorrectly treats as a valid binary. Subsequent execution attempts then fail with Windows SmartScreen / access denied errors.

What's Fixed

  • Detect and remove 0-byte binaries before use, ensuring corrupted downloads don't persist.
  • Validate downloaded binaries using SHA256 hashes (when available).
  • If a file fails validation, it is deleted and a clear error is thrown so the next run can retry a clean download.
  • Improved error handling and messaging when downloads fail or assets are unavailable.
  • Added test coverage for corrupted (0-byte) downloads and hash mismatches.

Improvements

  • The download process now uses the GitHub API to fetch release metadata before downloading the binary.
  • This enables retrieving file hashes and asset URLs reliably.
  • While it introduces an additional request, it significantly improves robustness and future extensibility.
  • Path handling has been hardened using canonicalization to avoid filesystem inconsistencies.
  • The architecture now allows future removal of hardcoded platform binary mappings by relying on GitHub-provided release data.

Impact

  • Prevents silent failures caused by antivirus software on Windows.
  • Ensures corrupted binaries are automatically detected and recovered from.
  • Provides clearer feedback to users when something goes wrong during installation or build steps.
  • Improves long-term maintainability of the binary download mechanism.

Recreated from #121, which GitHub won't allow reopening since its base branch (main) was renamed to 1.x (see #121 (comment)).

@bocharsky-bw

Copy link
Copy Markdown
Member

Thanks for recreating it! 👍🏼

I see we need to fix some merge conflicts here. Could you proceed with it?

@Taminoful

Copy link
Copy Markdown
Contributor Author

@bocharsky-bw Thanks for the heads up! Merged 1.x into this branch and resolved the conflicts:

  • .gitignore – kept both sets of additions (/tests/fixtures/var/, .idea/, and /config/reference.php).
  • src/TailwindBinary.php – combined the LogicException guard for a missing binary version (from 1.x) with the Path::canonicalize hardening from this PR in getBinaryPath().
  • tests/TailwindBinaryTest.php – kept both new test additions (the "constructed without binary/version" tests from 1.x and the corrupted-download/integrity-check tests from this PR).

Should be conflict-free and ready for another look. Let me know if anything needs adjusting! 🙂

@kbond kbond left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Taminoful, thanks for this PR!

It seems to me this PR is doing two things:

  1. detecting and failing when a 0-byte binary is downloaded
  2. integrity checking the downloads

Can we simplify this PR by just focusing on 1 - the bug fix? Just fail if the downloaded binary is 0 bytes. If the user is on windows, we could adjust the error message to hint at the possible problem as described.

The integrity check is a cool feature but could we do that in a follow-up PR? I think this can be simplified also. For instance, there's a sha256sums.txt attached with every release which would, I think save on API calls.

Comment thread .gitignore Outdated
@Taminoful

Copy link
Copy Markdown
Contributor Author

Hello @kbond,

Thanks for the review! On splitting the PR, I don't think that buys us much here. The 0-byte check and the integrity check aren't really two separate things, a 0-byte file is just one symptom of the same corrupted-download problem from #115. A partial download from the same AV-quarantine scenario would just fall through a filesize > 0 check and still be a broken binary. This approach was discussed with @bocharsky-bw here. Splitting means shipping the narrow fix now and then doing a second round of review on the same code paths (getBinaryPath(), downloadExecutable()) shortly after.

Regarding the sha256sums.txt question, both approaches are two requests, this PR does the api.github.com call plus the binary, sha256sums.txt would be one fetch of that file plus the binary. The difference is sha256sums.txt goes through the same non-API download endpoint as the binary, while the API call hits the rate-limited REST endpoint (AFAIK 60/hour unauthenticated). Doesn't cut the request count, but gets us off the rate limit, which could maybe matter for CI.

That said, there's a solid case for keeping the API too. It gives us the digest, size, and download URL as fields instead of us parsing a text file, which is not great. We're already pulling the asset list for name matching and to get the size for the progress bar, so the API call is already pulling its weight, as it replaces the previous approach of not knowing the size and matches the name against the actual name instead of reconstructing it. Adding sha256sums.txt on top of that just means we're maintaining two ways of getting the same information. Replacing the API call would mean we also would need to get the information for these in a different way or miss out on those completely. Coverage for API digests go back to v4.1.9, sha256sums.txt to v3.3.0, neither is complete.

More on my reasoning in 57f27d8 and 962b30b.

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.
From commit 57f27d8

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 #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.
-- From commit 962b30b.

The GitHub API gives us the full asset list in one shot, so we're iterating over that instead of fetching assets one by one, the content-type check is what filters sha256sums.txt out of that list.

Personally I find it adds unnecessary noise to the function for legacy versions that most likely aren't used for new projects. The goal was to keep the PR as clean as possible while making the entire download process more robust and easier to manage than it was beforehand.

I will go ahead and snip the .idea line from the .gitignore, as requested.

@Taminoful
Taminoful requested a review from kbond July 13, 2026 18:44
@kbond

kbond commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

On splitting the PR, I don't think that buys us much here. The 0-byte check and the integrity check aren't really two separate things, a 0-byte file is just one symptom

Fair enough, but I feel the solution here is too complex. I don't think the model classes are required. I'd prefer to just do the check right in the existing TailwindBinary class.

but gets us off the rate limit, which could maybe matter for CI.

We've been having issues with this in the CI, yes.

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.
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.
…asts#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
kbond added 2 commits July 22, 2026 21:30
Replace the `api.github.com` release-metadata call with a fetch of
`sha256sums.txt` from the release download host. The API endpoint is
rate limited (60/hour, shared by IP) and was reintroducing the CI
throttling the `ScopingHttpClient` auth work removed; `sha256sums.txt`
is served from the same non-rate-limited host as the binary itself.

The expected hash is now looked up inline in `TailwindBinary`, so the
`Model\TailwindBinaries` / `Model\TailwindBinary` DTOs are removed. The
check is best-effort: when `sha256sums.txt` or the binary's entry is
unavailable (older releases) the check is skipped and the download still
succeeds; only a genuine hash mismatch deletes the file and throws. This
drops the hardcoded `4.1.9` version gate.

Also revert the unrelated `Path::canonicalize` hardening (which pulled
`symfony/filesystem` into `src`) and the `\Exception` type change.
Throw a descriptive exception when a download produces a 0-byte file (a
common Windows antivirus symptom, see SymfonyCasts#115) instead of retrying
silently. Drop the now-redundant unlink in `getBinaryPath()` since
`downloadExecutable()`'s `fopen(..., 'w')` truncates any leftover file.
@kbond

kbond commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Thanks @Taminoful!

@kbond
kbond merged commit 9b0afba into SymfonyCasts:1.x Jul 23, 2026
23 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants