Skip to content

Uncaught UnexpectedValueException from Methods::getStorage() during taint analysis — method identifier case-normalized against a declaring class that doesn't have it #11933

Description

@Leon-Rakuten

Summary

Running psalm --taint-analysis over a production PHP codebase crashes the
entire run with an uncaught UnexpectedValueException raised deep inside
Psalm\Internal\Codebase\Methods::getStorage() (line 1154 on 6.16.1) instead of
reporting an UndefinedMethod-style finding.

The crash kills the whole scan with no report produced, which is fatal for
pipeline usage (we run Psalm's taint mode as one stage of a multi-tool
security scan).

Environment

  • Psalm: 6.16.1 (build f1f5de5), installed via composer global
  • PHP: 8.x CLI (macOS arm64)
  • Command: psalm --taint-analysis --output-format=json --report=<path> --no-progress --config=<generated psalm.xml>

Error

Uncaught Amp\Parallel\Worker\TaskFailureException: UnexpectedValueException thrown in context
with message "$storage should not be null for
App\Authenticate\Manager\LdapManagerUserInterface::setusername" and code "0"
in .../vimeo/psalm/src/Psalm/Internal/Codebase/Methods.php:1154

Stack trace in context:
#0 .../Psalm/Internal/Codebase/Methods.php(1154): Methods->getStorage(...)
#1 .../Psalm/Internal/Analyzer/MethodAnalyzer.php(73): Psalm\Internal\Codebase\Methods->getStorage(Object(Psalm\Internal\MethodIdentifier))
#2 .../Psalm/Internal/Analyzer/InterfaceAnalyzer.php(161): MethodAnalyzer->__construct(...)
#3 .../Psalm/Internal/Analyzer/FileAnalyzer.php(192): InterfaceAnalyzer->analyze()
#4 .../Psalm/Internal/Codebase/Analyzer.php(1486): FileAnalyzer->analyze()
#5 .../Psalm/Internal/Fork/AnalyzerTask.php(28): Analyzer::analysisWorker(...)
...
(Psalm 6.16.1@f1f5de594dc76faf8784e02d3dc4716c91c6f6ac crashed due to an uncaught Throwable)

Context (anonymized excerpts from the affected codebase)

The lookup that crashes is for setusername — note the lowercase method
name in the message. In the analyzed code, the same name exists in different
casing across related types:

// OpsUserInterface.php — declares the method as setUserName (capital N)
interface OpsUserInterface
{
    public function setUserName(string $username): self;
}

// OpsUser.php — implements it as setUsername (lowercase n), legal PHP
final class OpsUser implements OpsUserInterface
{
    public function setUsername(string $username): OpsUserInterface { ... }
}

// OpsUserProvider.php — property docblocks and a fluent call chain
/**
 * @var LdapManagerUserInterface
 */
private $ldapManager;

/**
 * @var OpsUserInterface
 */
private $userClass;

// ...
$user = $this->userClass;
$user
    ->setUsername($username)
    ->setManagerId($managerId)
    ->setDn($lm->getDn())
    ...

LdapManagerUserInterface is a separate interface that does not declare
any setUsername variant — yet Psalm constructs MethodIdentifier for
LdapManagerUserInterface::setusername (case-normalized) during interface
analysis, and getStorage() throws because that key has no storage.

Expected behavior

A method identifier that fails to resolve against a declaring class should
surface as a normal analyzer finding (e.g. UndefinedMethod /
MixedMethodCall-style) — or at most a skipped file with a warning. It must
never escape as an uncaught Throwable that terminates the entire analysis.

The root question is also worth looking at on its own: PHP method dispatch is
case-insensitive, so the interface/implementer casing mismatch above is valid
code; the storage lookup appears to lose the case-insensitive guarantee when
the identifier is normalized against a declaring class that received the
method via a case-mismatched implementation.

Notes

  • We could not reduce this below the multi-file Laravel-style project context;
    the excerpts above are the closest involved types. Happy to share a fuller
    reproduction project privately or test a patch against the original codebase.
  • Reproduced twice with identical stacks (identical run completed normally
    after the involved docblock annotations were corrected).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions