Skip to content

Commit

Permalink
Merge pull request #96 from boesing/bugfix/scanf-argument-count-mismatch
Browse files Browse the repository at this point in the history
Bugfix: `scanf` and `fscanf` argument count mismatch
  • Loading branch information
boesing committed May 29, 2022
2 parents c9722d6 + 2320460 commit d70603e
Show file tree
Hide file tree
Showing 17 changed files with 496 additions and 220 deletions.
1 change: 1 addition & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<exclude name="Generic.Files.LineLength.TooLong"/>
<exclude name="Squiz.NamingConventions.ValidVariableName.NotCamelCaps"/>
<exclude name="SlevomatCodingStandard.PHP.RequireExplicitAssertion.RequiredExplicitAssertion"/>
<exclude name="SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification"/>
</rule>

<file>src</file>
Expand Down
31 changes: 31 additions & 0 deletions src/ArgumentValidator/ArgumentValidationResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Boesing\PsalmPluginStringf\ArgumentValidator;

final class ArgumentValidationResult
{
/** @var 0|positive-int */
public int $requiredArgumentCount;

/** @var 0|positive-int */
public int $actualArgumentCount;

/**
* @param 0|positive-int $requiredArgumentCount
* @param 0|positive-int $actualArgumentCount
*/
public function __construct(
int $requiredArgumentCount,
int $actualArgumentCount
) {
$this->requiredArgumentCount = $requiredArgumentCount;
$this->actualArgumentCount = $actualArgumentCount;
}

public function valid(): bool
{
return $this->requiredArgumentCount === $this->actualArgumentCount;
}
}
17 changes: 17 additions & 0 deletions src/ArgumentValidator/ArgumentValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Boesing\PsalmPluginStringf\ArgumentValidator;

use Boesing\PsalmPluginStringf\Parser\TemplatedStringParser\TemplatedStringParser;
use PhpParser\Node\Arg;
use PhpParser\Node\VariadicPlaceholder;

interface ArgumentValidator
{
/**
* @param array<Arg|VariadicPlaceholder> $arguments
*/
public function validate(TemplatedStringParser $templatedStringParser, array $arguments): ArgumentValidationResult;
}
35 changes: 35 additions & 0 deletions src/ArgumentValidator/ScanfArgumentValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Boesing\PsalmPluginStringf\ArgumentValidator;

use Boesing\PsalmPluginStringf\Parser\TemplatedStringParser\TemplatedStringParser;

final class ScanfArgumentValidator implements ArgumentValidator
{
private ArgumentValidator $printfArgumentValidator;

public function __construct()
{
$this->printfArgumentValidator = new StringfArgumentValidator(2);
}

public function validate(TemplatedStringParser $templatedStringParser, array $arguments): ArgumentValidationResult
{
$result = $this->printfArgumentValidator->validate($templatedStringParser, $arguments);
if ($result->valid()) {
return $result;
}

if ($result->actualArgumentCount !== 0) {
return $result;
}

// sscanf and fscanf can return the arguments in case no arguments are passed
return new ArgumentValidationResult(
0,
0
);
}
}
53 changes: 53 additions & 0 deletions src/ArgumentValidator/StringfArgumentValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Boesing\PsalmPluginStringf\ArgumentValidator;

use Boesing\PsalmPluginStringf\Parser\TemplatedStringParser\TemplatedStringParser;
use PhpParser\Node\Arg;
use PhpParser\Node\VariadicPlaceholder;
use Webmozart\Assert\Assert;

final class StringfArgumentValidator implements ArgumentValidator
{
/** @var 0|positive-int */
private int $argumentsPriorPlaceholderArgumentsStart;

/**
* @param 0|positive-int $argumentsPriorPlaceholderArgumentsStart
*/
public function __construct(int $argumentsPriorPlaceholderArgumentsStart)
{
$this->argumentsPriorPlaceholderArgumentsStart = $argumentsPriorPlaceholderArgumentsStart;
}

public function validate(TemplatedStringParser $templatedStringParser, array $arguments): ArgumentValidationResult
{
$requiredArgumentCount = $templatedStringParser->getPlaceholderCount();
$currentArgumentCount = $this->countArguments($arguments) - $this->argumentsPriorPlaceholderArgumentsStart;
Assert::natural($currentArgumentCount);

return new ArgumentValidationResult(
$requiredArgumentCount,
$currentArgumentCount
);
}

/**
* @param array<Arg|VariadicPlaceholder> $arguments
*/
private function countArguments(array $arguments): int
{
$argumentCount = 0;
foreach ($arguments as $argument) {
if ($argument instanceof VariadicPlaceholder) {
continue;
}

$argumentCount++;
}

return $argumentCount;
}
}
181 changes: 181 additions & 0 deletions src/EventHandler/FunctionArgumentValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

declare(strict_types=1);

namespace Boesing\PsalmPluginStringf\EventHandler;

use Boesing\PsalmPluginStringf\ArgumentValidator\ArgumentValidator;
use Boesing\PsalmPluginStringf\Parser\Psalm\PhpVersion;
use Boesing\PsalmPluginStringf\Parser\TemplatedStringParser\TemplatedStringParser;
use InvalidArgumentException;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\VariadicPlaceholder;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\Issue\ArgumentIssue;
use Psalm\Issue\TooFewArguments;
use Psalm\Issue\TooManyArguments;
use Psalm\IssueBuffer;
use Psalm\Plugin\EventHandler\AfterEveryFunctionCallAnalysisInterface;
use Psalm\Plugin\EventHandler\Event\AfterEveryFunctionCallAnalysisEvent;
use Psalm\StatementsSource;

use function assert;
use function sprintf;

/**
* @psalm-consistent-constructor
*/
abstract class FunctionArgumentValidator implements AfterEveryFunctionCallAnalysisInterface
{
protected StatementsSource $statementsSource;

protected CodeLocation $codeLocation;

protected PhpVersion $phpVersion;

protected FuncCall $functionCall;

protected function __construct(StatementsSource $statementsSource, CodeLocation $codeLocation, PhpVersion $phpVersion, FuncCall $functionCall)
{
$this->statementsSource = $statementsSource;
$this->codeLocation = $codeLocation;
$this->phpVersion = $phpVersion;
$this->functionCall = $functionCall;
}

/**
* @return 0|positive-int
*/
abstract protected function getTemplateArgumentIndex(): int;

/**
* @return non-empty-string
*/
abstract protected function getIssueTemplate(): string;

abstract protected function getArgumentValidator(): ArgumentValidator;

private function createCodeIssue(
CodeLocation $codeLocation,
string $functionName,
int $argumentCount,
int $requiredArgumentCount
): ArgumentIssue {
$message = $this->createIssueMessage(
$functionName,
$requiredArgumentCount,
$argumentCount
);

if ($argumentCount < $requiredArgumentCount) {
return new TooFewArguments($message, $codeLocation, $functionName);
}

return new TooManyArguments($message, $codeLocation, $functionName);
}

/**
* @psalm-return non-empty-string
*/
private function createIssueMessage(string $functionName, int $requiredArgumentCount, int $argumentCount): string
{
$message = sprintf(
$this->getIssueTemplate(),
$functionName,
$requiredArgumentCount,
$argumentCount
);

assert($message !== '');

return $message;
}

/**
* @param non-empty-string $functionId
*/
abstract protected function canHandleFunction(string $functionId): bool;

public static function afterEveryFunctionCallAnalysis(AfterEveryFunctionCallAnalysisEvent $event): void
{
$functionId = $event->getFunctionId();
if ($functionId === '') {
return;
}

$functionCall = $event->getExpr();
$arguments = $functionCall->args;

$statementsSource = $event->getStatementsSource();

(new static($statementsSource, new CodeLocation($statementsSource, $functionCall), PhpVersion::fromCodebase($event->getCodebase()), $functionCall))->validate(
$functionId,
$arguments,
$event->getContext()
);
}

/**
* @param non-empty-string $functionName
* @param array<Arg|VariadicPlaceholder> $arguments
*/
private function validate(
string $functionName,
array $arguments,
Context $context
): void {
if (! $this->canHandleFunction($functionName)) {
return;
}

$templateArgumentIndex = $this->getTemplateArgumentIndex();
$template = null;

foreach ($arguments as $index => $argument) {
if ($index < $templateArgumentIndex) {
continue;
}

if ($argument instanceof VariadicPlaceholder) {
continue;
}

$template = $argument;
break;
}

// Unable to detect template argument
if ($template === null) {
return;
}

try {
$parsed = TemplatedStringParser::fromArgument(
$functionName,
$template,
$context,
$this->phpVersion->versionId,
false,
$this->statementsSource
);
} catch (InvalidArgumentException $exception) {
return;
}

$validator = $this->getArgumentValidator();
$validationResult = $validator->validate($parsed, $arguments);

if ($validationResult->valid()) {
return;
}

IssueBuffer::add($this->createCodeIssue(
$this->codeLocation,
$functionName,
$validationResult->actualArgumentCount,
$validationResult->requiredArgumentCount
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public static function afterEveryFunctionCallAnalysis(AfterEveryFunctionCallAnal
$statementsSource,
new CodeLocation($statementsSource, $expression),
$context,
PhpVersion::fromCodebase($event->getCodebase())
PhpVersion::fromCodebase($event->getCodebase())->versionId
);
}

Expand Down
42 changes: 42 additions & 0 deletions src/EventHandler/PrintfFunctionArgumentValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Boesing\PsalmPluginStringf\EventHandler;

use Boesing\PsalmPluginStringf\ArgumentValidator\ArgumentValidator;
use Boesing\PsalmPluginStringf\ArgumentValidator\StringfArgumentValidator;

use function in_array;

final class PrintfFunctionArgumentValidator extends FunctionArgumentValidator
{
private const FUNCTIONS = [
'sprintf',
'printf',
];

private const TEMPLATE_ARGUMENT_INDEX = 0;

private const ISSUE_TEMPLATE = 'Template passed to function `%s` requires %d specifier but %d are passed.';

protected function getTemplateArgumentIndex(): int
{
return self::TEMPLATE_ARGUMENT_INDEX;
}

protected function getIssueTemplate(): string
{
return self::ISSUE_TEMPLATE;
}

protected function canHandleFunction(string $functionId): bool
{
return in_array($functionId, self::FUNCTIONS, true);
}

protected function getArgumentValidator(): ArgumentValidator
{
return new StringfArgumentValidator(1);
}
}

0 comments on commit d70603e

Please sign in to comment.