Skip to content
Merged
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ discovered:
| `final` methods stay | a final method cannot be overridden either way, so a class carrying one is still refused |
| PHAR and preloaded classes | their source arrives as `phar://`, or before any bootstrap ran, so it never passes through the `file://` wrapper |
| The opcode cache is not a way back | however warm the cache is, and whether or not it holds the bypassed file — Linux keeps it out, Windows does not — the class stays open. Not being cached is a cost where it happens, not a guarantee to rely on |
| Another source transformer | if something else is already rewriting PHP source, understudy refuses rather than replacing it silently; a wrapper that leaves source alone composes and is accepted |
| Another `file://` wrapper | if a user-space wrapper already owns `file://`, understudy refuses rather than replacing it silently, whether or not that wrapper transforms PHP source |

When a class is still final at `for()`, the refusal says which of these it was —
bypass never asked for, asked for other classes but not this one, or asked for
Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Understudy::bypassFinals(); // все, что процесс
| `final`-методы остаются | такой метод не переопределить в любом случае, поэтому класс с ним по-прежнему отвергается |
| PHAR и preload | исходник приходит как `phar://` либо раньше любого bootstrap, поэтому через `file://`-враппер не проходит вовсе |
| Opcode-кэш не возвращает запечатанный класс | насколько бы тёплым кэш ни был и держит ли он bypass-нутый файл — Linux не держит, Windows держит — класс остаётся открытым. Отсутствие в кэше это цена там, где оно есть, а не гарантия, на которую стоит опираться |
| Чужой трансформер исходника | если PHP-исходник уже кто-то переписывает, understudy откажется, а не подменит молча; враппер, который исходник не трогает, композируется и принимается |
| Чужой `file://`-враппер | если `file://` уже обслуживает пользовательский враппер, understudy откажется, а не подменит его молча — независимо от того, меняет он PHP-исходник или нет |

Если класс всё ещё final в момент `for()`, отказ говорит, какой именно это
случай — bypass не включали вовсе, включили для других классов, но не для
Expand Down
14 changes: 7 additions & 7 deletions docs/.api-workspace/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions docs/scripts/rules-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"phpstan": {
"version": "v1.0.0",
"reference": "6d3fb1ca06d092dc613881efd6fe66bafa970914",
"repoBlob": "https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/",
"version": "v1.0.1",
"reference": "b002d987101830bf69bc35d74a64b63ea3627063",
"repoBlob": "https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/",
"registeredRules": [
"Rasuvaeff\\Understudy\\PhpStan\\Rule\\SpecificationCallRule",
"Rasuvaeff\\Understudy\\PhpStan\\Rule\\SpecificationStaticCallRule",
Expand Down
12 changes: 6 additions & 6 deletions docs/src/api/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ description: "Every identifier the PHPStan extension reports and every issue typ

Neither analyser package has an `@api` class, and that is correct rather than a missing annotation: `understudy-phpstan` is registered through `extension.neon` and `understudy-psalm` through `psalm-plugin enable`, so a user never names one of their classes. What a user does write is an **identifier**, in `ignoreErrors` or a suppression. That is the contract, so that is what is reflected here.

## PHPStan · `rasuvaeff/understudy-phpstan` v1.0.0
## PHPStan · `rasuvaeff/understudy-phpstan` v1.0.1

| Identifier | Reported by | Summary |
|---|---|---|
| `understudy.cardinality` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. |
| `understudy.closure` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. |
| `understudy.matcher` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. |
| `understudy.matcherLeak` | [`MatcherLeakRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Rule/MatcherLeakRule.php) | A matcher written outside a specification. |
| `understudy.returns` | [`VoidReturnsRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/6d3fb1ca06d092dc613881efd6fe66bafa970914/src/PhpStan/Rule/VoidReturnsRule.php) | `returns()` on a method that returns nothing. |
| `understudy.cardinality` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. |
| `understudy.closure` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. |
| `understudy.matcher` | [`SpecificationCheck`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Internal/SpecificationCheck.php) | Says what the engine would say, before the test runs. |
| `understudy.matcherLeak` | [`MatcherLeakRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Rule/MatcherLeakRule.php) | A matcher written outside a specification. |
| `understudy.returns` | [`VoidReturnsRule`](https://github.com/rasuvaeff/understudy-phpstan/blob/b002d987101830bf69bc35d74a64b63ea3627063/src/PhpStan/Rule/VoidReturnsRule.php) | `returns()` on a method that returns nothing. |

Silence one by its identifier:

Expand Down
2 changes: 1 addition & 1 deletion docs/src/guide/doubles/final-classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ than discovered.
| `final` methods stay | a final method cannot be overridden either way, so a class carrying one is still refused |
| PHAR and preloaded classes | their source arrives as `phar://`, or before any bootstrap ran, so it never passes through the `file://` wrapper |
| The opcode cache is not a way back | however warm the cache is, and whether or not it holds the bypassed file — Linux keeps it out, Windows does not — the class stays open. Not being cached is a cost where it happens, not a guarantee to rely on |
| Another source transformer | if something else is already rewriting PHP source, understudy refuses rather than replacing it silently; a wrapper that leaves source alone composes and is accepted |
| Another `file://` wrapper | if a user-space wrapper already owns `file://`, understudy refuses rather than replacing it silently, whether or not that wrapper transforms PHP source |

## The refusal tells you which one it was

Expand Down
4 changes: 2 additions & 2 deletions docs/src/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ Do not install it in production. `composer require --dev`.

[`bypassFinals()`](/guide/doubles/final-classes) rewrites source as it is read,
so a class really is not final any more for the rest of the process. It is
opt-in for exactly that reason, and it refuses to act rather than to compete
when another source transformer is already installed.
opt-in for exactly that reason, and it refuses to act rather than replace any
user-space wrapper that already owns `file://`.
8 changes: 4 additions & 4 deletions src/Exception/BypassUnavailable.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ public static function notAClass(string $class, string $kind): self
}

/**
* Builds the error for an already-installed foreign source wrapper.
* Builds the error for an already-installed user-space file wrapper.
*
* Something else already transforms PHP source on `file://`; replacing it
* would silently disable whatever it does.
* Something else owns `file://`; replacing it would silently disable
* whatever it does, whether or not it transforms PHP source.
*/
public static function foreignWrapper(string $owner): self
{
return new self(sprintf(
"Something else is already transforming PHP source read through `file://` (%s).\n"
"Something else is already serving `file://` through a user-space wrapper (%s).\n"
. "Replacing that wrapper would silently disable whatever it does, so understudy will not.\n"
. "- Call Understudy::bypassFinals() before the other wrapper is installed.\n"
. '- Or double an interface, which needs no wrapper at all.',
Expand Down
34 changes: 15 additions & 19 deletions src/Understudy.php
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,8 @@ public static function wire(string $sut, array $overrides = []): array
*/
public static function bypassFinals(?string $class = null): void
{
if (!FileWrapper::isInstalled() && self::foreignSourceTransform()) {
throw BypassUnavailable::foreignWrapper('the source it read back was not the source on disk');
if (!FileWrapper::isInstalled() && self::foreignFileWrapper()) {
throw BypassUnavailable::foreignWrapper('a user-space wrapper already owns `file://`');
}

if ($class === null) {
Expand Down Expand Up @@ -769,36 +769,32 @@ public static function bypassFinals(?string $class = null): void
}

/**
* Whether something already transforms PHP source read through `file://`.
*
* PHP exposes no owner for a protocol — `stream_get_wrappers()` lists
* `file` whoever handles it, and every register/restore call answers `true`
* either way — so the question is asked of behaviour instead. The stripper's
* own file declares `final class FinalStripper`, and a wrapper that strips
* `final` from class declarations, which is precisely the incompatible one,
* will have removed it by the time the bytes arrive here.
*
* What it catches: another source transformer. What it does not: a wrapper
* that leaves PHP source alone, which by definition composes with this one
* anyway. Not a guarantee dressed up as one.
* Whether a user-space wrapper already owns `file://`.
*
* Any existing user-space wrapper may have behaviour that replacing it
* would silently disable, so bypass refuses before installing its wrapper.
*/
private static function foreignSourceTransform(): bool
private static function foreignFileWrapper(): bool
{
$file = (new \ReflectionClass(FinalStripper::class))->getFileName();

if ($file === false) {
return false;
}

$source = @file_get_contents($file);
$stream = @fopen($file, 'r');

if ($source === false) {
// Nothing readable to compare against; refusing here would fail a
if ($stream === false) {
// Nothing readable to inspect; refusing here would fail a
// bypass for a reason that has nothing to do with wrappers.
return false;
}

return !str_contains($source, 'final class FinalStripper');
try {
return (stream_get_meta_data($stream)['wrapper_type'] ?? null) === 'user-space';
} finally {
fclose($stream);
}
}

/**
Expand Down
7 changes: 3 additions & 4 deletions tests/Fixture/Bypass/PassiveWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
/**
* Somebody else owning `file://` without touching PHP source.
*
* The refusal is deliberately narrow — it asks whether the source read back is
* the source on disk, not whether anyone else is present — so this one has to
* be *accepted*. Pinning that is what keeps the narrowness a decision rather
* than a gap nobody noticed.
* Understudy still refuses it: it cannot establish that replacing an existing
* user-space wrapper would be safe just because this fixture leaves source
* text alone.
*/
final class PassiveWrapper
{
Expand Down
8 changes: 5 additions & 3 deletions tests/Fixture/Bypass/scenario.php
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ class_exists(\Rasuvaeff\Understudy\Bypass\FinalStripper::class);

return 'installed over another stripper';
} catch (BypassUnavailable $refused) {
return str_contains($refused->getMessage(), 'the source it read back was not the source on disk')
return str_contains($refused->getMessage(), 'a user-space wrapper already owns `file://`')
? 'refused'
: 'refused with: ' . $refused->getMessage();
} finally {
Expand All @@ -287,9 +287,11 @@ class_exists(\Rasuvaeff\Understudy\Bypass\FinalStripper::class);
try {
Understudy::bypassFinals(SealedGate::class);

return 'accepted';
return 'installed over another wrapper';
} catch (BypassUnavailable $refused) {
return 'refused: ' . $refused->getMessage();
return str_contains($refused->getMessage(), 'a user-space wrapper already owns `file://`')
? 'refused'
: 'refused with: ' . $refused->getMessage();
} finally {
stream_wrapper_restore('file');
}
Expand Down
5 changes: 1 addition & 4 deletions tests/Integration/BypassFinalsIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,7 @@ public static function scenarioProvider(): iterable
'opened, line 19',
];
yield 'a second final-stripper on file:// is refused' => ['foreign-stripper', 'refused'];
// Deliberately the other way round: the refusal asks whether the
// source read back is the source on disk, not whether anyone else is
// there. A wrapper that leaves PHP source alone composes.
yield 'a wrapper that leaves source alone is accepted' => ['passive-wrapper', 'accepted'];
yield 'a wrapper that leaves source alone is also refused' => ['passive-wrapper', 'refused'];
yield 'a class nobody named stays final, and the refusal says so' => [
'bypass-for-another-class',
'refused, naming the omission',
Expand Down
2 changes: 1 addition & 1 deletion tests/Support/DispatchFindCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function run(mixed $model, mixed $system): array
}

return [
$answer === null ? null : $answer->title,
$answer?->title,
count(Understudy::calls(fn() => $system->double->find(Arg::any()))),
];
}
Expand Down
Loading