Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @psalm-require-usage annotation and report UnusedFunctionCall for @psalm-taint-escape calls #10742

Open
wants to merge 11 commits into
base: 5.x
Choose a base branch
from

Conversation

kkmuffme
Copy link
Contributor

@kkmuffme kkmuffme commented Feb 23, 2024

By default psalm considers all functions as impure and UnusedFunctionCall errors are only emitted for pure functions, since pure functions have no side-effects and therefore their return value must be used (otherwise the function call is useless)

There are some cases, where you want to force people to use the return value of a function call even if it's not pure - e.g. for escaping functions or when upgrading legacy code bases where a function returned void/never previously, but now returns a value that must get validated.
Currently, there is no valid way to do that (you could add @psalm-pure and suppress that on the function, but that will lead to other issues)

I propose a new @psalm-require-usage to get this behavior.
(I'd prefer @psalm-require-use however I think that might lead to confusion with @use/@template-use/@psalm-use)

Additionally, anything annotated with @psalm-taint-escape will also report unused function calls by default, since the escape function call only makes sense if the returned value is used.

Since phpstan doesn't offer this functionality in the first place (since by default it treats everything as pure https://phpstan.org/writing-php-code/phpdocs-basics#impure-functions), there are no PHP eco-system big picture concerns either.


Summary:

  • fix @psalm-pure + @psalm-assert-if-true not reporting UnusedFunctionCalls on methods https://psalm.dev/r/7093ba0a35 (already worked for functions https://psalm.dev/r/afb33d970e)
  • fix methods that return void should not be reporting UnusedMethodCall since the return value can't be used
  • report unused function/method calls if the function is annotated with @psalm-require-usage or has a @psalm-taint-escape annotation

@weirdan
Copy link
Collaborator

weirdan commented Feb 23, 2024

Overall this seems useful.

Am I right the proposed changes allow @psalm-pure + @psalm-assert-if-true function results to be unused? If so, I don't think that's right. Unlike @psalm-assert, which can be thought of as having a side effect (on type inference), side effects from @psalm-assert-if-true are conditional on using the return value, and require return value to be used to be realized. Thus https://psalm.dev/r/afb33d970e should still report UnusedFunctionCall, and it looks like a part of the proposed changes would prevent that.

Copy link

I found these snippets:

https://psalm.dev/r/afb33d970e
<?php

/**
 * @psalm-pure
 * @psalm-assert-if-true int|string $val
 */
function acceptable(mixed $val): bool {
    return is_string($val) || is_int($val);
}

acceptable($argv[0]);
Psalm output (using commit c488d40):

ERROR: UnusedFunctionCall - 11:1 - The call to acceptable is not used

@kkmuffme
Copy link
Contributor Author

@weirdan thanks, in that case I actually got it the wrong way round then - bc atm there is a bug that your example when wrapped in a class doesn't report an error: https://psalm.dev/r/7093ba0a35 - I assumed this is correct there and just ported that part from Methods to Functions - turns out this was a bug in methods - which I fixed now.

Copy link

I found these snippets:

https://psalm.dev/r/7093ba0a35
<?php

class Foo {
	/**
     * @psalm-pure
     * @psalm-assert-if-true int|string $val
     */
    public function acceptable(mixed $val): bool {
        return is_string($val) || is_int($val);
    }    
}

$foo = new Foo();
$foo->acceptable($argv[0]);
Psalm output (using commit c488d40):

No issues!

@kkmuffme kkmuffme force-pushed the require-usage-annotation branch from 10a56fb to 6125c55 Compare February 23, 2024 18:21
@kkmuffme kkmuffme marked this pull request as ready for review February 23, 2024 18:28
@kkmuffme
Copy link
Contributor Author

@weirdan could you please review if it's alright for you like that now - then I will remove all the unused @psalm-suppress in unrelated code too, before it gets merged (just want to avoid unnecessary re/undos)

@ohader
Copy link
Contributor

ohader commented Feb 24, 2024

This seems to be very useful! 👍
We had to deal with similar patterns in 3rd party code analyzing security vulnerabilities - like visualized in this example:

https://psalm.dev/r/4daa849fa9

/**
 * @psalm-require-usage
 */
class AccessDeniedResponse implements ResponseInterface {}

Questions concerning this PR

  • Would it quality to introduce a new CodeIssue class for that, e.g. MissingRequiredUsage instead of UnusedFunctionCall
  • Would it be possible to extend @psalm-require-usage to class instances that need to be used? Thus, basically making it available for other concepts (classes, variables, ...) as well...

Copy link

I found these snippets:

https://psalm.dev/r/4daa849fa9
<?php

/** PSR-7/PSR-15 stubs */

interface ResponseInterface {}
interface RequestHandlerInterface {}
interface ServerRequestInterface {}
class Response implements ResponseInterface
{
    public function __construct(string $message, int $code) {}
}


/** --- actual static analysis --- */

/**
 * @psalm-require-usage
 */
class AccessDeniedResponse implements ResponseInterface
{
    public function __construct(string $message, int $code) {}
}

class SomeController implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
		$this->authorize();
        // the possible `AccessDeniedResponse` of method `authorize` is not used,
        // as a result the un-authorized process execution continues...
        return new Response('All good... Here are the secrets...', 200);
    }

    private function authorize(): ?ResponseInterface
    {
        if ($this->anyAuthorizationCheck() === false) {
            return new AccessDeniedResponse('Forbidden', 403);
        }
        return null;
    }

    private function anyAuthorizationCheck(): bool
    {
        return false;
    }
}
Psalm output (using commit c488d40):

ERROR: InvalidDocblock - 19:7 - Unrecognised annotation @psalm-require-usage in docblock for AccessDeniedResponse

ERROR: InvalidDocblock - 19:1 - Unrecognised annotation @psalm-require-usage

@weirdan
Copy link
Collaborator

weirdan commented Feb 25, 2024

@ohader the thing you're describing looks much more complicated then what's proposed here. While a return value usage is fairly easy to define (any read operation), usage of an atomic in a union is much less clear. E.g. would all of the following constitute usage of AccessDenied?

https://psalm.dev/r/fcb87e545b

Also note that in all of those cases we don't really see AccessDenied in any return type, only its parent.

Copy link

I found these snippets:

https://psalm.dev/r/fcb87e545b
<?php

interface Response {}

final class AccessDenied implements Response {}
final class Ok implements Response {}

function authorize(): ?Response {
    return rand(0, 1) ? null : (rand(0, 1) ? new AccessDenied : new Ok);
}

function f1(): void {
    if (authorize() === null) {
        // was Response used here?
        echo 'secrets';
    }
}

function f2(): void {
    if (authorize()) {
        // was it used here?
        throw new Exception;
    }
    echo 'secrets';
}

function f3(): void {
    if (!authorize() instanceof Response) {
        // what about this?
        echo 'secrets';
    }
}
Psalm output (using commit c488d40):

No issues!

Copy link
Collaborator

@weirdan weirdan left a comment

Choose a reason for hiding this comment

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

We should probably add one more thing here. Since void|never + require-usage is pointless it would make sense to flag those combinations. E.g.

/**
 * @psalm-require-usage
 */
function f(): never { exit; } 
// InvalidDocblock(?): @require-usage is incompatible with `never` return type

docs/annotating_code/supported_annotations.md Outdated Show resolved Hide resolved
@ohader
Copy link
Contributor

ohader commented Feb 25, 2024

@weirdan Thanks for providing the examples! I agree that my idea would make it much more complicated. Besides that, I did not want to block this change nor request any changes in particular. Sorry for hijacking this PR with my own agenda...

@kkmuffme kkmuffme force-pushed the require-usage-annotation branch from d60e59a to 5b4276c Compare February 29, 2024 16:50
@kkmuffme kkmuffme force-pushed the require-usage-annotation branch from 4ba1bb9 to ecdc9a4 Compare February 29, 2024 20:50
@kkmuffme
Copy link
Contributor Author

@weirdan added errors for invalid annotations and updated tests. It's done.

@kkmuffme kkmuffme requested a review from weirdan February 29, 2024 20:55
@kkmuffme
Copy link
Contributor Author

@weirdan can you please merge this

@kkmuffme
Copy link
Contributor Author

May I ask for a code review :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants