diff --git a/docs/capabilities.md b/docs/capabilities.md index eb634599301..2b83b6d6564 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -1,3 +1,5 @@ +Wrote /compiler/docs/capabilities.md (279 builtins). +Wrote /compiler/docs/stdlib-jit-audit.md (missing=0, deferred=0, present=265) # Capability matrix Auto-generated by `script/capability-matrix.php`. Do not edit by hand. @@ -149,6 +151,7 @@ Auto-generated by `script/capability-matrix.php`. Do not edit by hand. | `is_scalar` | yes | yes | yes | standard | | | `is_string` | yes | yes | yes | types | JIT PHPT; AOT PHPT | | `is_subclass_of` | yes | yes | yes | standard | | +| `is_uploaded_file` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `is_writable` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `join` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `json_decode` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | diff --git a/docs/stdlib-jit-audit.md b/docs/stdlib-jit-audit.md index e198ca4868e..746d8d6e594 100644 --- a/docs/stdlib-jit-audit.md +++ b/docs/stdlib-jit-audit.md @@ -4,8 +4,8 @@ Auto-generated by `script/audit-stdlib-jit.php`. Regenerate: `php script/audit-s | Metric | Count | |--------|------:| -| `call()` implementations | 263 | -| With JitStringArg/JitLongArg (or zero-arg LLVM) | 261 | +| `call()` implementations | 264 | +| With JitStringArg/JitLongArg (or zero-arg LLVM) | 262 | | Missing arg helpers (actionable for JIT) | 0 | | Deferred (VM-only) | 0 | | Self-host auto-stub batch | 0 | @@ -153,6 +153,7 @@ _None — all JIT `call()` builtins are lowered or deferred._ - `is_readable` — `ext/standard/is_readable.php` - `is_scalar` — `ext/standard/is_scalar.php` - `is_subclass_of` — `ext/standard/is_subclass_of_.php` +- `is_uploaded_file` — `ext/standard/is_uploaded_file.php` - `is_writable` — `ext/standard/is_writable.php` - `json_decode` — `ext/standard/json_decode.php` - `json_encode` — `ext/standard/json_encode.php` diff --git a/ext/standard/JitIsUploadedFile.php b/ext/standard/JitIsUploadedFile.php new file mode 100644 index 00000000000..f4838bfdecc --- /dev/null +++ b/ext/standard/JitIsUploadedFile.php @@ -0,0 +1,29 @@ +getTypeFromString('int32'); + $ret = $context->builder->call( + $context->lookupFunction('__compiler_is_uploaded_file'), + $pathStr + ); + $one = $i32->constInt(1, false); + + return $context->builder->icmp(Builder::INT_EQ, $ret, $one); + } +} diff --git a/ext/standard/JitMoveUploadedFile.php b/ext/standard/JitMoveUploadedFile.php index 56ee3f981ad..9155d100521 100644 --- a/ext/standard/JitMoveUploadedFile.php +++ b/ext/standard/JitMoveUploadedFile.php @@ -4,6 +4,7 @@ namespace PHPCompiler\ext\standard; +use PHPCompiler\JIT\Builtin\StringFsDir; use PHPCompiler\JIT\Context; use PHPLLVM\Builder; use PHPLLVM\Value; @@ -14,6 +15,8 @@ final class JitMoveUploadedFile /** @return Value */ public static function invoke(Context $context, Value $fromStr, Value $toStr): Value { + StringFsDir::ensureLinked($context); + $i32 = $context->getTypeFromString('int32'); $ret = $context->builder->call( $context->lookupFunction('__compiler_move_uploaded_file'), diff --git a/ext/standard/Module.php b/ext/standard/Module.php index 1908bd0c823..9a16eee33b8 100755 --- a/ext/standard/Module.php +++ b/ext/standard/Module.php @@ -226,6 +226,7 @@ public function getFunctions(): array new chmod_(), new rename_(), new move_uploaded_file(), + new is_uploaded_file(), new copy_(), new move_uploaded_file(), new touch_(), diff --git a/ext/standard/is_uploaded_file.php b/ext/standard/is_uploaded_file.php new file mode 100644 index 00000000000..6e4d6e8c0f1 --- /dev/null +++ b/ext/standard/is_uploaded_file.php @@ -0,0 +1,46 @@ +calledArgs)) { + throw new \LogicException('is_uploaded_file() requires exactly one argument in this compiler build'); + } + if (null === $frame->returnVar) { + return; + } + $pathVar = $frame->calledArgs[0]->resolveIndirect(); + if (Variable::TYPE_STRING !== $pathVar->type) { + throw new \LogicException('is_uploaded_file() requires a string path in this compiler build'); + } + $frame->returnVar->bool(VmFs::isValidUploadTempPath($pathVar->toString())); + } + + public function call(Context $context, JITVariable ...$args): Value + { + if (1 !== \count($args)) { + throw new \LogicException('is_uploaded_file() requires exactly one argument in this compiler build'); + } + $path = $this->jitString($context, $args[0], 'is_uploaded_file() path'); + + return JitIsUploadedFile::invoke($context, $path); + } +} diff --git a/lib/AOT/Linker.php b/lib/AOT/Linker.php index 0720d364ce8..febd1e527f5 100644 --- a/lib/AOT/Linker.php +++ b/lib/AOT/Linker.php @@ -22,6 +22,7 @@ final class Linker __DIR__.'/runtime/strtr.c', __DIR__.'/runtime/filter_validate.c', __DIR__.'/runtime/phpc_fs_dir.c', + __DIR__.'/runtime/phpc_upload_temp.c', __DIR__.'/runtime/phpc_session_id_storage.c', __DIR__.'/runtime/phpc_session_name_storage.c', __DIR__.'/runtime/phpc_value_box.c', @@ -50,6 +51,7 @@ final class Linker /** Runtime units that need host libc headers (glob/scandir; llvm sysroot lacks linux/limits.h). */ private const RUNTIME_HOST_LIBC_BASENAMES = [ 'phpc_fs_dir.c', + 'phpc_upload_temp.c', 'preg_match.c', 'password_crypto.c', ]; diff --git a/lib/AOT/runtime/builtin_function_names.inc b/lib/AOT/runtime/builtin_function_names.inc index ef5a0fdb184..b69196b4621 100644 --- a/lib/AOT/runtime/builtin_function_names.inc +++ b/lib/AOT/runtime/builtin_function_names.inc @@ -51,6 +51,7 @@ static const char *phpc_builtin_functions[] = { "count", "crc32", "date", + "debug_backtrace", "decbin", "dechex", "decoct", @@ -102,6 +103,7 @@ static const char *phpc_builtin_functions[] = { "glob", "gmdate", "hash", + "hash_equals", "hash_hmac", "header", "header_list", @@ -141,6 +143,7 @@ static const char *phpc_builtin_functions[] = { "is_scalar", "is_string", "is_subclass_of", + "is_uploaded_file", "is_writable", "join", "json_decode", @@ -151,8 +154,8 @@ static const char *phpc_builtin_functions[] = { "lstat", "ltrim", "max", - "md5", "mb_strlen", + "md5", "method_exists", "microtime", "min", @@ -195,6 +198,7 @@ static const char *phpc_builtin_functions[] = { "readlink", "realpath", "rename", + "restore_error_handler", "rmdir", "round", "rtrim", @@ -206,6 +210,7 @@ static const char *phpc_builtin_functions[] = { "session_regenerate_id", "session_start", "session_write_close", + "set_error_handler", "setcookie", "setrawcookie", "sha1", @@ -275,4 +280,4 @@ static const char *phpc_builtin_functions[] = { "web_string", "wordwrap", }; -static const size_t phpc_builtin_functions_count = 268; +static const size_t phpc_builtin_functions_count = 279; diff --git a/lib/AOT/runtime/phpc_fs_dir.c b/lib/AOT/runtime/phpc_fs_dir.c index f36d2b348e4..472cf020091 100644 --- a/lib/AOT/runtime/phpc_fs_dir.c +++ b/lib/AOT/runtime/phpc_fs_dir.c @@ -389,106 +389,6 @@ __string__ *__compiler_sys_get_temp_dir(void) return cstr_to_string(dir); } -#define PHPC_UPLOAD_TEMP_PREFIX "phpc_upload_" - -static int phpc_path_has_parent_traversal(const char *path) -{ - const char *p; - const char *start; - - if (NULL == path) { - return 1; - } - start = path; - for (p = path; ; p++) { - if ('\0' == *p || '/' == *p) { - size_t len = (size_t) (p - start); - if (2 == len && 0 == strncmp(start, "..", 2)) { - return 1; - } - if ('\0' == *p) { - break; - } - start = p + 1; - } - } - - return 0; -} - -static int phpc_is_valid_upload_temp(const char *path) -{ - char resolved[PATH_MAX]; - char tmpdir[PATH_MAX]; - const char *base; - const char *dir; - char *real_from; - char *real_tmp; - size_t tmp_len; - - if (NULL == path || '\0' == path[0] || phpc_path_has_parent_traversal(path)) { - return 0; - } - base = strrchr(path, '/'); - base = (NULL != base) ? base + 1 : path; - if (0 != strncmp(base, PHPC_UPLOAD_TEMP_PREFIX, strlen(PHPC_UPLOAD_TEMP_PREFIX))) { - return 0; - } - real_from = realpath(path, resolved); - if (NULL == real_from) { - return 0; - } - dir = getenv("TMPDIR"); - if (NULL == dir || '\0' == *dir) { - dir = getenv("TEMP"); - } - if (NULL == dir || '\0' == *dir) { - dir = getenv("TMP"); - } - if (NULL == dir || '\0' == *dir) { - dir = "/tmp"; - } - real_tmp = realpath(dir, tmpdir); - if (NULL == real_tmp) { - return 0; - } - tmp_len = strlen(real_tmp); - if (tmp_len + 1 >= sizeof(tmpdir)) { - return 0; - } - if ('/' != real_tmp[tmp_len - 1]) { - real_tmp[tmp_len] = '/'; - real_tmp[tmp_len + 1] = '\0'; - tmp_len++; - } - if (0 != strncmp(real_from, real_tmp, tmp_len)) { - return 0; - } - - return 1; -} - -/** move_uploaded_file() — rename upload temp only under system temp (issue #2005). */ -int __compiler_move_uploaded_file(__string__ *from, __string__ *to) -{ - const char *src; - const char *dst; - - if (NULL == from || NULL == to) { - return 0; - } - src = phpc_strdata(from); - dst = phpc_strdata(to); - if (!phpc_is_valid_upload_temp(src) || phpc_path_has_parent_traversal(dst) || '\0' == dst[0]) { - return 0; - } - if (0 != rename(src, dst)) { - return 0; - } - - return 1; -} - /** tempnam() — unique temp path in directory with prefix (issue #1201, #2005). */ __string__ *__compiler_tempnam(__string__ *directory, __string__ *prefix) { diff --git a/lib/AOT/runtime/phpc_upload_temp.c b/lib/AOT/runtime/phpc_upload_temp.c new file mode 100644 index 00000000000..35e03325a75 --- /dev/null +++ b/lib/AOT/runtime/phpc_upload_temp.c @@ -0,0 +1,133 @@ +/* + * Upload temp validation for is_uploaded_file() / move_uploaded_file() (issues #2005, #2204). + * Uses libc realpath/rename; no PHP internal wrappers. + */ + +#include +#include +#include +#include +#include + +typedef struct __string__ __string__; + +extern __string__ *__string__init(long long size, const char *value); + +static const char *phpc_strdata(__string__ *s) +{ + if (NULL == s) { + return ""; + } + + return (const char *) s + sizeof(void *) + sizeof(long long); +} + +#define PHPC_UPLOAD_TEMP_PREFIX "phpc_upload_" + +static int phpc_path_has_parent_traversal(const char *path) +{ + const char *p; + const char *start; + + if (NULL == path) { + return 1; + } + start = path; + for (p = path; ; p++) { + if ('\0' == *p || '/' == *p) { + size_t len = (size_t) (p - start); + if (2 == len && 0 == strncmp(start, "..", 2)) { + return 1; + } + if ('\0' == *p) { + break; + } + start = p + 1; + } + } + + return 0; +} + +static int phpc_is_valid_upload_temp(const char *path) +{ + char resolved[PATH_MAX]; + char tmpdir[PATH_MAX]; + const char *base; + const char *dir; + char *real_from; + char *real_tmp; + size_t tmp_len; + + if (NULL == path || '\0' == path[0] || phpc_path_has_parent_traversal(path)) { + return 0; + } + base = strrchr(path, '/'); + base = (NULL != base) ? base + 1 : path; + if (0 != strncmp(base, PHPC_UPLOAD_TEMP_PREFIX, strlen(PHPC_UPLOAD_TEMP_PREFIX))) { + return 0; + } + real_from = realpath(path, resolved); + if (NULL == real_from) { + return 0; + } + dir = getenv("TMPDIR"); + if (NULL == dir || '\0' == *dir) { + dir = getenv("TEMP"); + } + if (NULL == dir || '\0' == *dir) { + dir = getenv("TMP"); + } + if (NULL == dir || '\0' == *dir) { + dir = "/tmp"; + } + real_tmp = realpath(dir, tmpdir); + if (NULL == real_tmp) { + return 0; + } + tmp_len = strlen(real_tmp); + if (tmp_len + 1 >= sizeof(tmpdir)) { + return 0; + } + if ('/' != real_tmp[tmp_len - 1]) { + real_tmp[tmp_len] = '/'; + real_tmp[tmp_len + 1] = '\0'; + tmp_len++; + } + if (0 != strncmp(real_from, real_tmp, tmp_len)) { + return 0; + } + + return 1; +} + +/** is_uploaded_file() — validate multipart upload temp path (issue #2204). */ +int __compiler_is_uploaded_file(__string__ *path) +{ + if (NULL == path) { + return 0; + } + + return phpc_is_valid_upload_temp(phpc_strdata(path)); +} + +/** move_uploaded_file() — rename upload temp only under system temp (issue #2005). */ +int __compiler_move_uploaded_file(__string__ *from, __string__ *to) +{ + const char *src; + const char *dst; + + if (NULL == from || NULL == to) { + return 0; + } + src = phpc_strdata(from); + dst = phpc_strdata(to); + if (!phpc_is_valid_upload_temp(src) || phpc_path_has_parent_traversal(dst) || '\0' == dst[0]) { + return 0; + } + if (0 != rename(src, dst)) { + return 0; + } + + return 1; +} diff --git a/lib/JIT/Builtin/StringFsDir.php b/lib/JIT/Builtin/StringFsDir.php new file mode 100644 index 00000000000..5837085ede4 --- /dev/null +++ b/lib/JIT/Builtin/StringFsDir.php @@ -0,0 +1,182 @@ +loadType) { + return; + } + + $probe = $context->module->getNamedFunction('__compiler_is_uploaded_file'); + if (null !== $probe && $probe->countBasicBlocks() > 0) { + self::registerLinkedRuntime($context); + + return; + } + + $bitcode = self::ensureBitcode(); + $data = file_get_contents($bitcode); + if (false === $data || '' === $data) { + throw new \LogicException('Failed to read fs_dir JIT bitcode: '.$bitcode); + } + $buffer = $context->llvm->createMemoryBufferWithString($data, 'phpc_fs_dir.bc'); + $runtimeModule = $buffer->parseBitcode($context->context); + if (!$context->module->link($runtimeModule)) { + throw new \LogicException('Failed to link fs_dir JIT runtime bitcode'); + } + + self::registerLinkedRuntime($context); + } + + private static function registerLinkedRuntime(Context $context): void + { + foreach (['__compiler_is_uploaded_file', '__compiler_move_uploaded_file'] as $name) { + $fn = $context->module->getNamedFunction($name); + if (null === $fn) { + throw new \LogicException($name.' missing after fs_dir bitcode link'); + } + $context->registerFunction($name, $fn); + } + } + + private static function ensureBitcode(): string + { + $source = realpath(self::RUNTIME_SOURCE); + if (false === $source || !is_file($source)) { + throw new \LogicException('fs_dir runtime source not found: '.self::RUNTIME_SOURCE); + } + + $compiler = self::resolveCompiler(); + $cacheDir = sys_get_temp_dir().'/phpc-jit-runtime'; + if (!is_dir($cacheDir) && !mkdir($cacheDir, 0777, true) && !is_dir($cacheDir)) { + throw new \LogicException('Cannot create JIT runtime cache: '.$cacheDir); + } + + $cache = $cacheDir.'/'.basename($source, '.c').'-'.substr( + sha1($source.filemtime($source).$compiler.'host'), + 0, + 16 + ).'.bc'; + if (is_file($cache) && filemtime($cache) >= filemtime($source)) { + return $cache; + } + + $includes = self::hostLibcIncludeFlags(); + $cmd = escapeshellarg($compiler) + .' -emit-llvm -c -fPIC -O2'.$includes.' ' + .escapeshellarg($source).' -o '.escapeshellarg($cache).' 2>&1'; + $output = shell_exec($cmd); + if (!is_file($cache)) { + throw new \LogicException( + 'Failed to compile fs_dir JIT bitcode: '.trim((string) $output) + ); + } + + return $cache; + } + + private static function resolveCompiler(): string + { + $llvmDir = getenv('PHP_COMPILER_LLVM_PATH'); + if (false !== $llvmDir && '' !== $llvmDir) { + foreach (['clang-9', 'clang'] as $name) { + $candidate = $llvmDir.'/'.$name; + if (is_executable($candidate)) { + return $candidate; + } + } + } + + foreach (['clang-9', 'clang', 'gcc', 'cc'] as $name) { + $path = trim((string) shell_exec('command -v '.escapeshellarg($name).' 2>/dev/null')); + if ('' !== $path) { + return $path; + } + } + + throw new \LogicException('No C compiler found for fs_dir JIT runtime bitcode'); + } + + private static function hostLibcIncludeFlags(): string + { + $flags = ''; + foreach (self::discoverSystemIncludeDirs() as $dir) { + $flags .= ' -isystem '.escapeshellarg($dir); + } + if ('' === $flags && is_file('/usr/include/stdio.h')) { + $flags = ' -isystem /usr/include'; + } + + return $flags; + } + + /** + * @return list + */ + private static function discoverSystemIncludeDirs(): array + { + $dirs = []; + foreach (['gcc', 'cc', 'clang'] as $compiler) { + $path = trim((string) shell_exec('command -v '.escapeshellarg($compiler).' 2>/dev/null')); + if ('' === $path) { + continue; + } + $verbose = shell_exec( + escapeshellarg($path).' -E -Wp,-v -xc /dev/null 2>&1' + ); + if (!is_string($verbose)) { + continue; + } + $capture = false; + foreach (explode("\n", $verbose) as $line) { + if (str_contains($line, '#include <...> search starts here:')) { + $capture = true; + + continue; + } + if ($capture) { + if (str_contains($line, 'End of search list')) { + break; + } + $dir = trim($line); + if ('' !== $dir && is_dir($dir)) { + $dirs[$dir] = true; + } + } + } + if ([] !== $dirs) { + break; + } + } + + if ([] === $dirs) { + foreach (['/usr/include', '/usr/include/x86_64-linux-gnu'] as $fallback) { + if (is_dir($fallback)) { + $dirs[$fallback] = true; + } + } + } + + return array_keys($dirs); + } +} diff --git a/lib/JIT/Builtin/Type.php b/lib/JIT/Builtin/Type.php index f491f728a2b..e7d1fe85389 100755 --- a/lib/JIT/Builtin/Type.php +++ b/lib/JIT/Builtin/Type.php @@ -258,6 +258,16 @@ public function register(): void { $fntypeMoveUploaded ); $this->context->registerFunction('__compiler_move_uploaded_file', $fnMoveUploaded); + $fntypeIsUploaded = $this->context->context->functionType( + $i32, + false, + $this->context->getTypeFromString('__string__*') + ); + $fnIsUploaded = $this->context->module->addFunction( + '__compiler_is_uploaded_file', + $fntypeIsUploaded + ); + $this->context->registerFunction('__compiler_is_uploaded_file', $fnIsUploaded); $fntypeTouch = $this->context->context->functionType( $i32, false, diff --git a/lib/JIT/SelfHostBuiltinPolicy.php b/lib/JIT/SelfHostBuiltinPolicy.php index 6d354c14777..d0b688e5c88 100644 --- a/lib/JIT/SelfHostBuiltinPolicy.php +++ b/lib/JIT/SelfHostBuiltinPolicy.php @@ -78,7 +78,7 @@ final class SelfHostBuiltinPolicy 'ftell' => 'filesystem', 'fseek' => 'filesystem', 'fclose' => 'filesystem', 'feof' => 'filesystem', 'fflush' => 'filesystem', 'fpassthru' => 'filesystem', 'pathinfo' => 'filesystem', 'readfile' => 'filesystem', 'readlink' => 'filesystem', 'rename' => 'filesystem', - 'move_uploaded_file' => 'filesystem', 'touch' => 'filesystem', + 'is_uploaded_file' => 'filesystem', 'move_uploaded_file' => 'filesystem', 'touch' => 'filesystem', 'getenv' => 'filesystem', 'putenv' => 'filesystem', 'sys_get_temp_dir' => 'filesystem', 'tempnam' => 'filesystem', 'getcwd' => 'filesystem', 'chdir' => 'filesystem', 'stream_context_create' => 'filesystem', diff --git a/test/compliance/IsUploadedFileJITTest.php b/test/compliance/IsUploadedFileJITTest.php new file mode 100644 index 00000000000..78934e177a3 --- /dev/null +++ b/test/compliance/IsUploadedFileJITTest.php @@ -0,0 +1,34 @@ + self::parsePHPT( + __DIR__.'/cases/stdlib/is_uploaded_file_jit.phpt', + 'is_uploaded_file_jit.phpt' + ); + } + + public function setUp(): void + { + $this->BIN = realpath(__DIR__.'/../../bin/jit.php'); + if (!LlvmToolchain::isReady(dirname(__DIR__, 2))) { + $this->markTestSkipped(LlvmToolchain::readyFailureReason() ?? 'LLVM 9 not available'); + } + } +} diff --git a/test/compliance/IsUploadedFileVMTest.php b/test/compliance/IsUploadedFileVMTest.php new file mode 100644 index 00000000000..a8b7a25cbdd --- /dev/null +++ b/test/compliance/IsUploadedFileVMTest.php @@ -0,0 +1,26 @@ + self::parsePHPT( + __DIR__.'/cases/stdlib/is_uploaded_file.phpt', + 'is_uploaded_file.phpt' + ); + } + + public function setUp(): void + { + $this->BIN = realpath(__DIR__.'/../../bin/vm.php'); + } +} diff --git a/test/compliance/cases/stdlib/is_uploaded_file.phpt b/test/compliance/cases/stdlib/is_uploaded_file.phpt new file mode 100644 index 00000000000..c2c5a7f61f1 --- /dev/null +++ b/test/compliance/cases/stdlib/is_uploaded_file.phpt @@ -0,0 +1,49 @@ +--TEST-- +stdlib is_uploaded_file() (issue #2204) +--FILE-- +assertNotFalse($tmp); + file_put_contents($tmp, 'x'); + + $runtime = new Runtime(); + $fn = new is_uploaded_file(); + $frame = $fn->getFrame($runtime->vmContext); + $path = new VMVariable(); + $path->string($tmp); + $frame->calledArgs = [$path]; + $frame->returnVar = new VMVariable(); + $fn->execute($frame); + $this->assertTrue($frame->returnVar->resolveIndirect()->toBool()); + @unlink($tmp); + } + + public function testRejectsPlainTemp(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'phpc_plain_'); + $this->assertNotFalse($tmp); + file_put_contents($tmp, 'x'); + + $runtime = new Runtime(); + $fn = new is_uploaded_file(); + $frame = $fn->getFrame($runtime->vmContext); + $path = new VMVariable(); + $path->string($tmp); + $frame->calledArgs = [$path]; + $frame->returnVar = new VMVariable(); + $fn->execute($frame); + $this->assertFalse($frame->returnVar->resolveIndirect()->toBool()); + @unlink($tmp); + } +}