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
70 changes: 70 additions & 0 deletions ext/standard/JitCsvArg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace PHPCompiler\ext\standard;

use PHPCompiler\JIT\BasicBlockHelper;
use PHPCompiler\JIT\Builtin\TypeErrorRaise;
use PHPCompiler\JIT\Context;
use PHPCompiler\JIT\Variable as JITVariable;
use PHPLLVM\Builder;
use PHPLLVM\Value;

/** LLVM validation for fputcsv() CSV option strings (#4530, ext/standard/file.c). */
final class JitCsvArg
{
public static function validateFputcsvCall(Context $context, int $argc, JITVariable ...$args): void
{
if ($argc >= 3) {
self::validateArg($context, $args[2], 3, 'separator', false);
}
if ($argc >= 4) {
self::validateArg($context, $args[3], 4, 'enclosure', false);
}
if ($argc >= 5) {
self::validateArg($context, $args[4], 5, 'escape', true);
}
}

private static function validateArg(
Context $context,
JITVariable $arg,
int $argNum,
string $paramName,
bool $allowEmpty,
): void {
if (null !== ($arg->compileTimeString ?? null)) {
return;
}

$message = $allowEmpty
? \sprintf('fputcsv(): Argument #%d ($%s) must be empty or a single character', $argNum, $paramName)
: \sprintf('fputcsv(): Argument #%d ($%s) must be a single character', $argNum, $paramName);

TypeErrorRaise::registerDeclarations($context);
TypeErrorRaise::ensureLinked($context);
$strPtr = $context->builder->load(
$context->helper->loadValue($arg)
);
$map = $context->structFieldMap['__string__'];
$len = $context->builder->load(
$context->builder->structGep($strPtr, $map['length'])
);
$i64 = $context->getTypeFromString('int64');
$one = $i64->constInt(1, false);
$failBlock = BasicBlockHelper::append($context, 'fputcsv_csv_arg_fail_'.$argNum);
$okBlock = BasicBlockHelper::append($context, 'fputcsv_csv_arg_ok_'.$argNum);
if ($allowEmpty) {
$tooLong = $context->builder->icmp(Builder::INT_SGT, $len, $one);
$context->builder->branchIf($tooLong, $failBlock, $okBlock);
} else {
$notOne = $context->builder->icmp(Builder::INT_NE, $len, $one);
$context->builder->branchIf($notOne, $failBlock, $okBlock);
}
$context->builder->positionAtEnd($failBlock);
TypeErrorRaise::emitValueError($context, $message);
$context->builder->call($context->lookupFunction('abort'));
$context->builder->positionAtEnd($okBlock);
}
}
43 changes: 43 additions & 0 deletions ext/standard/VmCsvArg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace PHPCompiler\ext\standard;

/**
* CSV delimiter/enclosure/escape validation (php-src ext/standard/file.c, issues #4530, #1193).
*/
final class VmCsvArg
{
public static function validateFputcsvOptions(
string $separator,
string $enclosure,
string $escape,
): void {
self::requireSingleChar($separator, 3, 'separator');
self::requireSingleChar($enclosure, 4, 'enclosure');
self::requireEmptyOrSingleChar($escape, 5, 'escape');
}

public static function requireSingleChar(string $value, int $argNum, string $paramName): void
{
if (1 !== \strlen($value)) {
throw new \ValueError(\sprintf(
'fputcsv(): Argument #%d ($%s) must be a single character',
$argNum,
$paramName
));
}
}

public static function requireEmptyOrSingleChar(string $value, int $argNum, string $paramName): void
{
if (\strlen($value) > 1) {
throw new \ValueError(\sprintf(
'fputcsv(): Argument #%d ($%s) must be empty or a single character',
$argNum,
$paramName
));
}
}
}
46 changes: 46 additions & 0 deletions ext/standard/fputcsv.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use PHPCompiler\Frame;
use PHPCompiler\Func\Internal;
use PHPCompiler\JIT\BasicBlockHelper;
use PHPCompiler\JIT\Builtin\TypeErrorRaise;
use PHPCompiler\JIT\Context;
use PHPCompiler\JIT\HashTableHelper;
use PHPCompiler\JIT\JitLongArg;
Expand Down Expand Up @@ -49,6 +51,7 @@ public function execute(Frame $frame): void
if ($argc >= 5) {
$escape = VmReflection::stringArg($frame->calledArgs[4], 'fputcsv() escape', 4);
}
VmCsvArg::validateFputcsvOptions($separator, $enclosure, $escape);
$fields = [];
foreach ($fieldsVar->toArray()->iterate(true) as $value) {
$value = $value->resolveIndirect();
Expand Down Expand Up @@ -95,6 +98,11 @@ public function call(Context $context, JITVariable ...$args): Value
if ($argc < 2 || $argc > 5) {
throw new \LogicException('fputcsv() requires two to five arguments in this compiler build');
}
$compileTimeFailure = $this->emitCompileTimeCsvValidationFailure($context, $argc, ...$args);
if (null !== $compileTimeFailure) {
return $compileTimeFailure;
}
JitCsvArg::validateFputcsvCall($context, $argc, ...$args);
$i64 = $context->getTypeFromString('int64');
$strPtr = $context->getTypeFromString('__string__*');
$handle = $context->builder->truncOrBitCast(
Expand Down Expand Up @@ -135,4 +143,42 @@ private function loadFields(Context $context, JITVariable $arg): Value

throw new \LogicException('fputcsv() fields must be an array in this compiler build');
}

private function emitCompileTimeCsvValidationFailure(Context $context, int $argc, JITVariable ...$args): ?Value
{
$checks = [
[3, 'separator', false, 2],
[4, 'enclosure', false, 3],
[5, 'escape', true, 4],
];
foreach ($checks as [$argNum, $paramName, $allowEmpty, $argIndex]) {
if ($argc < $argNum) {
continue;
}
$literal = $args[$argIndex]->compileTimeString ?? null;
if (null === $literal) {
continue;
}
$invalid = $allowEmpty ? \strlen($literal) > 1 : 1 !== \strlen($literal);
if (!$invalid) {
continue;
}
$message = $allowEmpty
? \sprintf('fputcsv(): Argument #%d ($%s) must be empty or a single character', $argNum, $paramName)
: \sprintf('fputcsv(): Argument #%d ($%s) must be a single character', $argNum, $paramName);
TypeErrorRaise::registerDeclarations($context);
TypeErrorRaise::ensureLinked($context);
$errBlock = BasicBlockHelper::append($context, 'fputcsv_csv_lit_err_'.$argNum);
$afterBlock = BasicBlockHelper::append($context, 'fputcsv_csv_lit_after_'.$argNum);
$context->builder->branch($errBlock);
$context->builder->positionAtEnd($errBlock);
TypeErrorRaise::emitValueError($context, $message);
$context->builder->call($context->lookupFunction('abort'));
$context->builder->positionAtEnd($afterBlock);

return $context->getTypeFromString('int64')->constInt(0, false);
}

return null;
}
}
11 changes: 11 additions & 0 deletions test/compliance/cases/stdlib/fputcsv_bad_handle.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
--TEST--
stdlib fputcsv() — bad stream handle TypeError (#4530, ext/standard/file.c)
--FILE--
<?php
try {
fputcsv(123, ['a']);
} catch (Throwable $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
--EXPECT--
TypeError: fputcsv(): Argument #1 ($stream) must be of type resource, int given
25 changes: 25 additions & 0 deletions test/compliance/cases/stdlib/fputcsv_escape.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
--TEST--
stdlib fputcsv() — empty escape/enclosure ValueError parity (#4530, ext/standard/file.c)
--FILE--
<?php
$fp = fopen('php://memory', 'r+');
foreach ([
['escape' => ''],
['enclosure' => ''],
['enclosure' => 'ab'],
['separator' => ''],
['escape' => 'ab'],
] as $case) {
try {
fputcsv($fp, ['a'], $case['separator'] ?? ',', $case['enclosure'] ?? '"', $case['escape'] ?? '\\');
echo "no throw\n";
} catch (Throwable $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
}
--EXPECT--
no throw
ValueError: fputcsv(): Argument #4 ($enclosure) must be a single character
ValueError: fputcsv(): Argument #4 ($enclosure) must be a single character
ValueError: fputcsv(): Argument #3 ($separator) must be a single character
ValueError: fputcsv(): Argument #5 ($escape) must be empty or a single character
14 changes: 14 additions & 0 deletions test/compliance/cases/stdlib/fputcsv_escape_jit.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--TEST--
stdlib fputcsv() JIT — empty enclosure ValueError (#4530, ext/standard/file.c)
--JIT--
--FILE--
<?php
$fp = fopen('php://memory', 'r+');
try {
fputcsv($fp, ['a'], ',', '', '\\');
echo "no throw\n";
} catch (Throwable $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
--EXPECT--
ValueError: fputcsv(): Argument #4 ($enclosure) must be a single character
11 changes: 11 additions & 0 deletions test/fixtures/aot/compile-only/fputcsv_escape.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

// Compile-only (#4530): fputcsv() CSV option ValueError lowering for AOT lint.
$fp = fopen('php://memory', 'r+');
try {
fputcsv($fp, ['a'], ',', '', '\\');
} catch (Throwable $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
41 changes: 41 additions & 0 deletions test/unit/FputcsvEscapeJitCompileTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace PHPCompiler;

use PHPUnit\Framework\TestCase;

/**
* AOT lint verify for fputcsv() delimiter/enclosure/escape ValueError (#4530).
*
* php-src: ext/standard/file.c — PHP_FUNCTION(fputcsv)
*/
final class FputcsvEscapeJitCompileTest extends TestCase
{
public function testFputcsvEscapeAotLint(): void
{
$root = dirname(__DIR__, 2);
$bin = realpath($root.'/bin/compile.php');
$this->assertNotFalse($bin);
$target = $root.'/test/fixtures/aot/compile-only/fputcsv_escape.php';
$cmd = [PHP_BINARY, $bin, '-l', $target];
$descriptorSpec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptorSpec, $pipes, $root);
$this->assertIsResource($proc);
fclose($pipes[0]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$exit = proc_close($proc);
$this->assertSame(
0,
$exit,
trim($stderr !== false ? $stderr : '')."\n".'compile.php -l failed for fputcsv escape probe (#4530)'
);
}
}