Skip to content
Draft
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
43 changes: 43 additions & 0 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: PHP

on:
pull_request:
branches: ["master", "main"]
push:
branches: ["master", "main"]

permissions:
contents: read

jobs:
test:
name: lint + phpunit
runs-on: ubuntu-latest
# 7.4 rather than 8.x: lib/ sets properties dynamically, which PHP 8.2
# deprecates, and the library's own floor is php >= 5.3.19. phpunit 9.6
# supports 7.3+.
container: php:7.4-cli
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3

# Runs first and on its own: a syntax error must fail the build even if the
# suite cannot boot.
- name: Syntax check
run: |
php -l lib/Local.php
php -l lib/LocalBinary.php
php -l lib/LocalException.php
php -l tests/LocalTest.php
php -l tests/LocalBinaryTest.php

# git + unzip are not in the official php image, and Composer needs one of
# them to unpack downloaded packages (ext-zip is not built in either).
- name: Install dependencies
run: |
apt-get update -qq && apt-get install -y -qq --no-install-recommends git unzip >/dev/null
curl -sS https://getcomposer.org/installer | php
php composer.phar install --no-interaction --no-progress

# Excludes @group network — those tests reach badssl.com and the real S3 host.
- name: PHPUnit
run: ./vendor/bin/phpunit --exclude-group network
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,45 @@ To specify the path to file where the logs will be saved -
$bs_local_args = array("key" => "<browserstack-accesskey>", "logfile" => "/browserstack/logs.txt");
```

## Upgrading

### TLS verification on the binary download

Earlier releases downloaded the BrowserStack Local binary with TLS certificate
verification disabled. **Verification is now enforced**, and the download also
rejects a response that is not a valid binary rather than storing it.

If your machine sits behind a TLS-inspecting proxy whose certificate authority is
not in the system trust store, the download will now fail instead of silently
succeeding, and you will see:

```
Download failed (cURL error 60): SSL certificate problem: self signed certificate in certificate chain
```

**Remedy:** install your organisation's CA certificate into the system trust store
(libcurl honours it) — on Debian/Ubuntu, drop the PEM into
`/usr/local/share/ca-certificates/` and run `update-ca-certificates`; on macOS, add
it to the System keychain and mark it trusted; on Windows, import it into
*Trusted Root Certification Authorities*. Alternatively, download the binary out of
band and point the library at it with the `binaryPath` argument.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[blocking] This escape hatch does not work on this branch, and it is the only remedy offered to a user who cannot modify the system trust store.

EvidencebinaryPath is accepted and then immediately discarded:

// lib/Local.php:66-70  — add_args() stores it
elseif ($arg_key == "binaryPath")
  $this->binary_path = $value;

// lib/Local.php:112-114 — start() overwrites it unconditionally
$this->binary = new LocalBinary();
$this->binary_path = $this->binary->binary_path();   // ← the user's path is gone

LocalBinary is constructed with no arguments and has no way to receive the caller's path, so binary_path() goes down the download path regardless. For the exact user this README section addresses — behind a TLS-inspecting proxy — that download now raises LocalException, so following this advice leaves them with the same hard failure they started with.

This is a known open bug, not my inference: PR #27 ("bug: BrowserStack\Local does not pass along binary_path to BrowserStack\LocalBinary") exists to fix it and is still open.

Why this is blocking rather than a doc nit: a user with no admin rights on a locked-down machine cannot install a CA into the system trust store, so binaryPath is their only listed option. Shipping it as a remedy sends them down a path that cannot work, and this text lands in the release that first enforces verification.

Fix — pick one:

  1. Drop the sentence, and instead point at pinning the previous release (composer require browserstack/browserstack-local:<old>) as the stopgap.
  2. Land the one-line binaryPath plumbing here (or merge bug: BrowserStack\Local does not pass along binary_path to BrowserStack\LocalBinary #27 first) and keep the sentence — then it is true.

If you take (2), please also add a test that start(array('binaryPath' => …)) does not download, since nothing currently covers it.


We do not provide a switch to disable verification: it is what protects the binary
you are about to execute from being substituted in transit.

## Contribute

Testing is possible using [PHPUnit](https://phpunit.de/).

To run the tests, run the command: `phpunit`

`@group network` tests reach external hosts. To skip them:
`phpunit --exclude-group network`.

`tests/manual/php-poc.sh` is an end-to-end demonstration that the binary download
refuses a substituted binary served over an untrusted TLS certificate. It needs
`php`, `git`, `openssl` and `python3`, and is run by hand — not part of the suite.

### Reporting bugs

You can submit bug reports either in the Github issue tracker.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"php": ">=5.3.19"
},
"require-dev": {
"phpunit/phpunit": "4.6.*"
"phpunit/phpunit": "^9.6"
},
"suggest": {
"phpdocumentor/phpdocumentor": "2.*"
Expand Down
151 changes: 126 additions & 25 deletions lib/LocalBinary.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@

class LocalBinary {

const DOWNLOAD_ATTEMPTS = 3;
const CONNECT_TIMEOUT = 10;
const DOWNLOAD_TIMEOUT = 300;

// Any real BrowserStackLocal build is tens of MB. A gateway error page or a
// truncated transfer is orders of magnitude smaller.
const MIN_BINARY_SIZE = 1048576;

public function __construct() {
$this->possible_binary_paths = array(
$this->server_home() . "/.browserstack",
Expand All @@ -22,17 +30,18 @@

public function binary_path() {
$dest_parent_dir = $this->get_available_dirs();
$dest_binary_name = "BrowserStackLocal";
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$dest_binary_name = $dest_binary_name. ".exe";
}
$binary_path = $dest_parent_dir. "/". $dest_binary_name;
if(file_exists($binary_path)){
return $binary_path;
}
else {
return $this->download_binary($dest_parent_dir);
$binary_path = $dest_parent_dir. "/". $this->dest_binary_name();
if (file_exists($binary_path)) {
if ($this->verify_binary($binary_path)) {
$this->make_executable($binary_path);
return $binary_path;
}
// A cached file that is not a usable binary is discarded rather than
// executed — it may be a stored error page or a partial download.
// nosemgrep: php.lang.security.unlink-use.unlink-use -- basename is fixed by dest_binary_name(), no user input in the path
unlink($binary_path);

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: php.lang.security.unlink-use.unlink-use Warning

Using user input when deleting files with unlink() is potentially dangerous. A malicious actor could use this to modify or access files they have no right to.
}
return $this->download_binary($dest_parent_dir);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[for-human] Scope question — this PR closes the network substitution vector, but the same CWE-494 primitive remains locally reachable through the download directory, and that decision belongs to a human, not to another fix round.

Evidence: __construct() sets possible_binary_paths to $HOME/.browserstack, getcwd(), sys_get_temp_dir() (lines 21–25), and get_available_dirs() (line 188) returns the first that exists or can be created. When $HOME is unset or unwritable — a container, a CI runner with no HOME, a system daemon — the target becomes getcwd() or /tmp.

In a world-writable /tmp, a local attacker pre-plants BrowserStackLocal:

  • Plant any ELF ≥ 1 MiB → verify_binary() passes (it is a format gate, not an authenticity check, as the comment on line 153 correctly says) → make_executable() chmods it 0755 → Local::start() executes it. The download never happens.
  • Plant a symlink at that name → fetch_binary()'s fopen($dest_binary_path, "w+") (line 121) writes 36 MB through to the link target.

This is pre-existing, and this PR does not widen itsys_get_temp_dir() and the unconditional chmod 0755 were both there before, and the new cached-file check on line 41 arguably narrows it, since a planted non-executable is now deleted instead of run. I am raising it because it is the same primitive in the same function this PR is hardening, and because the ticket's ask ("TLS validation and SHA verification") is broader than the network vector alone.

The call: widen scope here (e.g. drop sys_get_temp_dir()/getcwd() from the fallback list, or mkdir a 0700 per-user subdirectory and refuse a target that is world-writable or a symlink), or file it as a third follow-up next to the SHA-256 one. Either is defensible; it should not be decided silently.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not actioned in this round — escalating rather than deciding, which I think is what you are asking for. Recorded as follow-up #3 in the fix summary and the test report alongside the SHA-256 one, so it reaches the human with the ticket rather than dying in a PR thread.

Your read is correct on both the mechanism and the blast radius, and I verified the two facts it rests on: __construct() does fall back to getcwd() then sys_get_temp_dir(), and verify_binary() is explicitly a format gate, so a planted ≥1 MiB ELF passes it and make_executable() then chmods it 0755. The symlink case through fopen(..., "w+") is real too.

My reasoning for not widening here, offered as input to that decision rather than as the decision:

  1. It is a different threat model — local attacker with write access to a shared directory, versus the on-path network attacker LOC-6740 is about. Fixing both in one diff makes the security review of the TLS change harder, and the TLS change is the one with the 31-July commitment.
  2. Every candidate remedy is a behaviour change with real breakage risk. Dropping sys_get_temp_dir()/getcwd() breaks exactly the container and no-HOME CI cases you named — those users currently work, and would start hard-failing. A 0700 per-user subdirectory changes the cached-binary location, so every existing install re-downloads once. Both need functional testing across three platforms, and I have no PHP runtime to do it — shipping that untested next to the security fix is how you get a rollback that takes the security fix with it.
  3. It is pre-existing and arguably narrowed by this PR, as you note: a planted non-executable is now deleted instead of run.

My recommendation for the follow-up, if it helps: mkdir a 0700 per-user directory and refuse a target that is a symlink or world-writable, sequenced after the release tag for this fix so the two changes can be reverted independently. Happy to implement it in a separate PR if the human agrees — it should not ride this one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[for-human] Carried forward from round 0, unchanged in this diff and still a human scope call — not a defect to fix in another round.

possible_binary_paths (lines 21–25) is still $HOME/.browserstack, getcwd(), sys_get_temp_dir(), and get_available_dirs() still returns the first that exists or can be created. When $HOME is unset or unwritable — a container, a CI runner with no HOME, a system daemon — the download target becomes getcwd() or /tmp.

In a world-writable /tmp, a local attacker can pre-plant BrowserStackLocal: any ELF ≥ 1 MiB satisfies verify_binary() (correctly documented at line 155 as a format gate, not an authenticity check), make_executable() grants 0755, and Local::start() executes it — the download never happens. Or plant a symlink, and fetch_binary()'s fopen($dest_binary_path, "w+") at line 122 writes through to the link target.

Pre-existing and not widened by this PRsys_get_temp_dir() and the unconditional chmod 0755 both predate it, and the new cached-file check at line 42 arguably narrows it, since a planted non-executable is now deleted rather than run. I am re-raising it only because it is the same CWE-494 primitive in the function this PR hardens, and security's ask on the ticket is broader than the network vector.

The call: widen scope here (drop getcwd()/sys_get_temp_dir() from the fallback, or mkdir a 0700 per-user directory and refuse a world-writable or symlinked target), or file it as a fourth follow-up beside the SHA-256 one. Either is defensible — it just should not pass silently a second time.

}

private function server_home() {
Expand All @@ -52,10 +61,20 @@
return empty($home) ? NULL : $home;
}

private function platform_url(){
private function is_windows() {
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
}

private function dest_binary_name() {
return $this->is_windows() ? "BrowserStackLocal.exe" : "BrowserStackLocal";
}

// protected so tests can point the download at a local fixture without
// patching installed source.
protected function platform_url(){
if (PHP_OS == "Darwin")
return 'https://s3.amazonaws.com/browserStack/browserstack-local/BrowserStackLocal-darwin-x64';
else if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN')
else if ($this->is_windows())
return 'https://s3.amazonaws.com/browserStack/browserstack-local/BrowserStackLocal.exe';
if ((strtoupper(PHP_OS)) == "LINUX") {
if (PHP_INT_SIZE * 8 == 64)
Expand All @@ -67,27 +86,109 @@

public function download_binary($path) {
$url = $this->platform_url();
if (empty($url))
throw new LocalException("No BrowserStack Local binary is available for platform " . PHP_OS);

if (!file_exists($path))
mkdir($path, 0777, true);

$dest_binary_path = $path. '/'. $this->dest_binary_name();
$last_error = "unknown error";

$dest_binary_name = "BrowserStackLocal";
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$dest_binary_name = $dest_binary_name. ".exe";
for ($attempt = 1; $attempt <= self::DOWNLOAD_ATTEMPTS; $attempt++) {
try {
$this->fetch_binary($url, $dest_binary_path);
if ($this->verify_binary($dest_binary_path)) {
// Execute permission is granted only after the download has been
// verified, never before.
$this->make_executable($dest_binary_path);
return $dest_binary_path;
}
$last_error = "downloaded file is not a valid BrowserStackLocal binary";
}
catch (LocalException $e) {
$last_error = $e->getMessage();
}
// Never leave an unverified file behind for a later run to pick up and execute.
// nosemgrep: php.lang.security.unlink-use.unlink-use -- basename is fixed by dest_binary_name(), no user input in the path
if (file_exists($dest_binary_path))
unlink($dest_binary_path);

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: php.lang.security.unlink-use.unlink-use Warning

Using user input when deleting files with unlink() is potentially dangerous. A malicious actor could use this to modify or access files they have no right to.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[nit] This PR adds two net-new open code-scanning alerts — php.lang.security.unlink-use on this line and on line 41 (alerts #6 and #7, posted by github-advanced-security on this PR).

Both are false positives, and I checked rather than assumed: the unlink target is <dir>/BrowserStackLocal or <dir>/BrowserStackLocal.exe in both cases, with the basename produced by dest_binary_name() (line 67) — no user-controlled component in the filename, so no traversal. download_binary($path) is public and its $path is caller-supplied, but the basename is still fixed, so the worst a caller can do is delete a file they already named.

On the red semgrep/ci check: not a regression from this PR — that job fails identically on master on every scheduled run going back to at least 2026-07-20. Do not treat it as a gate here; it is separate repo hygiene.

Fix: dismiss both alerts as false-positive in the code-scanning UI, or annotate them so the dashboard for a security ticket does not accrue new open findings:

// nosemgrep: php.lang.security.unlink-use.unlink-use -- fixed basename from dest_binary_name(), no user input in path
unlink($dest_binary_path);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d0f1b91 — added the nosemgrep annotation you suggested to both unlink() calls, with the reason inline (basename fixed by dest_binary_name(), no user-controlled path component).

Your analysis matches mine: download_binary($path) is public and its $path is caller-supplied, but the basename is never attacker-influenced, so the worst a caller achieves is deleting a file they themselves named. Annotating rather than dismissing in the UI so the reasoning is in the diff and survives a re-scan.

Agreed on the red semgrep/ci check being pre-existing repo hygiene and not a gate for this PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[nit] Both nosemgrep annotations are ineffective — I verified against the API rather than assuming, and alerts #6 and #7 are still open on this head.

GET /repos/browserstack/browserstack-local-php/code-scanning/alerts?ref=refs/pull/29/merge
7  open  php.lang.security.unlink-use.unlink-use  lib/LocalBinary.php  115
6  open  php.lang.security.unlink-use.unlink-use  lib/LocalBinary.php  42

Those line numbers are the post-round-1 layout (they were 41 and 113 before the annotations were inserted), so the scan did re-run on the new code and still reports both.

Two separate causes:

  1. The trailing prose breaks rule-ID matching (affects both sites). Semgrep parses everything after nosemgrep: as a comma-separated rule-ID list. Yours becomes php.lang.security.unlink-use.unlink-use -- basename is fixed by dest_binary_name() plus no user input in the path — neither is a valid ID, so nothing is suppressed. The reason has to live outside the directive.
  2. Site 2 is on the wrong line. The directive at line 113 applies to line 114 (if (file_exists(...))), but the finding is on line 115 (unlink(...)).

Fix — justification on its own comment line, bare directive immediately above the flagged statement:

      // Never leave an unverified file behind for a later run to pick up and execute.
      // Path basename is fixed by dest_binary_name(); no user-controlled component.
      if (file_exists($dest_binary_path))
        // nosemgrep: php.lang.security.unlink-use.unlink-use
        unlink($dest_binary_path);

Same shape for line 41/42. Still cosmetic — both findings are genuine false positives — but worth getting right so a security ticket does not leave two open alerts behind. And to repeat from round 0 so it is not misread as a regression: the red semgrep/ci check fails identically on master on every scheduled run and is not caused by this PR.

}
$dest_binary_path = $path. '/'. $dest_binary_name;
$file = fopen($dest_binary_path , "w+");
$ch = curl_init("");
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

throw new LocalException("Error trying to download BrowserStack Local binary from " .
$url . " after " . self::DOWNLOAD_ATTEMPTS . " attempts. Last error: " . $last_error);
}

protected function fetch_binary($url, $dest_binary_path) {
$file = fopen($dest_binary_path, "w+");
if ($file === false)
throw new LocalException("Unable to open " . $dest_binary_path . " for writing");

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
// The binary is executed on the user's machine, so the transport that
// delivers it must be authenticated: validate the certificate chain and
// that the certificate matches the host we asked for.
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[nit] This is a deliberate breaking change and it ships with no user-facing note anywhere.

The PR description and the assessment on LOC-6740 both record functional_impact: YES, and both justify it by pointing at the Ruby gem: "same break the Ruby gem's 1.5.0 CHANGELOG already announced." That comparison is the problem — the Ruby gem told its users. This repo has no CHANGELOG file at all, and this diff touches neither it nor README.md.

Why it bites harder here than in Ruby: the download path has no CURLOPT_PROXY and no custom-CA option, so a user behind a TLS-inspecting corporate proxy has exactly one remedy — install the corporate CA into the system trust store (which libcurl does honour). They go from silent success to a hard LocalException with no documented path forward. Whether that is a five-minute fix or a support ticket depends entirely on a note existing.

This is also time-sensitive relative to follow-up #1: the note should land before the release tag, since the tag is what actually delivers the break to composer require users.

Fix: add a short "Upgrading" section to README.md (and/or a CHANGELOG.md entry) naming three things — the new behaviour, the literal cURL error 60 / SSL certificate problem string users will see, and the trust-store remedy.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d0f1b91 — added an "Upgrading" section to README.md.

The Ruby comparison was doing work it had not earned, and you are right that it argued against me: the gem told its users, this repo had no CHANGELOG and I touched neither it nor the README. Your point about it being time-sensitive relative to the release tag is the part that decided it — the tag is what delivers the break, so the note has to precede it.

The section names the three things you asked for: the new behaviour, the literal Download failed (cURL error 60): SSL certificate problem… string users will actually see, and the trust-store remedy with per-OS steps (Debian/Ubuntu update-ca-certificates, macOS System keychain, Windows Trusted Root store), plus binaryPath as an out-of-band escape hatch. I also stated explicitly that there is deliberately no switch to disable verification, so nobody files that as a feature request.

I went with README over a new CHANGELOG.md: the repo has never had one, and starting one whose only entry is this change seemed less discoverable than the README a composer require user already reads.

curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
// A redirect must not be able to downgrade the transfer to plaintext.
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
curl_setopt($ch, CURLOPT_FILE, $file);
$data = curl_exec ($ch);
curl_close ($ch);

curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::CONNECT_TIMEOUT);
curl_setopt($ch, CURLOPT_TIMEOUT, self::DOWNLOAD_TIMEOUT);
$result = curl_exec($ch);
$curl_errno = curl_errno($ch);
$curl_error = curl_error($ch);
$http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($file);
chmod($dest_binary_path, 0755);
return $dest_binary_path;

if ($result === false || $curl_errno !== 0)
throw new LocalException("Download failed (cURL error " . $curl_errno . "): " . $curl_error);

if ($http_status < 200 || $http_status >= 300)
throw new LocalException("Download failed with HTTP status " . $http_status);
}

// Confirms the bytes on disk are a platform executable of plausible size.
// This catches error pages, truncated transfers and empty files; it is not an
// authenticity check — that is the job of TLS chain validation in
// fetch_binary(). The file is deliberately not executed to test it.
protected function verify_binary($binary_path) {
// The retry loop stats the same path up to DOWNLOAD_ATTEMPTS times, so a
// cached stat entry from a previous attempt must not decide this one.
clearstatcache(true, $binary_path);
if (!is_file($binary_path))
return false;
if (filesize($binary_path) < self::MIN_BINARY_SIZE)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[nit] filesize() results are cached by PHP per-path for the life of the request, and the new retry loop calls this on the same path up to three times.

Scenario: attempt 1 stores a short S3 AccessDenied/503 body → verify_binary() stats the path, filesize() returns e.g. 243, gate fails → unlink() → attempt 2 downloads the genuine 36 MB binary → if the cached stat entry is still live, filesize() still reports 243, the gate rejects a perfectly good binary, it gets deleted, and after 3 attempts the user gets a hard LocalException on a working network.

What I could not confirm: whether PHP's automatic stat-cache invalidation on unlink() covers this. I have no PHP runtime available (the same wall the PR description hit), and the retry path has no test — so this is a question, not a confirmed defect.

Fix — one line makes it correct regardless of the answer, so it is cheaper than establishing which way the semantics fall:

protected function verify_binary($binary_path) {
    clearstatcache(true, $binary_path);
    if (!is_file($binary_path))
      return false;

Same reasoning applies to the is_executable() check in make_executable() (line 183), which is stat-cached too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d0f1b91clearstatcache(true, $binary_path) at the top of verify_binary(), and the same in make_executable() for the stat-cached is_executable() you flagged.

Taking your framing: I could not establish PHP's invalidation semantics either (same missing runtime), and the one-line guarantee costs less than settling the question. The scenario you describe is the one that worried me too — a short error body poisoning the size check for the next attempt would turn a transient S3 blip into a hard LocalException on a working network, which is a worse failure than the one this PR fixes.

Worth noting the retry path still has no automated coverage — the existing tests exercise a single attempt. Recorded as a gap in the test report rather than papered over.

return false;

$handle = fopen($binary_path, "rb");
if ($handle === false)
return false;
$magic = fread($handle, 4);
fclose($handle);
if ($magic === false || strlen($magic) < 4)
return false;

if ($this->is_windows())
$valid = (substr($magic, 0, 2) === "MZ");
else if (PHP_OS == "Darwin")
// Mach-O 64/32-bit little-endian, plus the universal ("fat") header.
$valid = in_array($magic, array("\xcf\xfa\xed\xfe", "\xce\xfa\xed\xfe", "\xca\xfe\xba\xbe"), true);
else
$valid = ($magic === "\x7f" . "ELF");

return $valid;
}

private function make_executable($binary_path) {
clearstatcache(true, $binary_path);
if ($this->is_windows() || is_executable($binary_path))
return true;
return @chmod($binary_path, 0755);
}

private function get_available_dirs() {
Expand Down
9 changes: 6 additions & 3 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<phpunit>
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="local">
<directory>tests</directory>
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
</phpunit>

Loading
Loading