From 9c3e0029f79e146da5935d5505377f3d5a02b4a1 Mon Sep 17 00:00:00 2001 From: PurHur Date: Mon, 25 May 2026 12:12:22 +0000 Subject: [PATCH] Add move_uploaded_file() for VM and AOT multipart uploads (#2005). Implement temp-path validation for phpc_upload_* files, AOT runtime __compiler_move_uploaded_file, compliance/AOT PHPT coverage, and skip JIT until a dedicated lowering test lands. Co-authored-by: Cursor --- docs/capabilities.md | 1 + ext/standard/JitMoveUploadedFile.php | 27 ++++++ ext/standard/Module.php | 1 + ext/standard/VmUpload.php | 63 +++++++++++++ ext/standard/move_uploaded_file_.php | 53 +++++++++++ lib/AOT/runtime/builtin_function_names.inc | 1 + lib/AOT/runtime/phpc_fs_dir.c | 90 +++++++++++++++++++ lib/JIT/Builtin/Type.php | 8 ++ lib/JIT/SelfHostBuiltinPolicy.php | 3 +- test/compliance/JITTest.php | 4 + .../cases/stdlib/move_uploaded_file.phpt | 49 ++++++++++ .../aot/cases/move_uploaded_file.phpt | 22 +++++ 12 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 ext/standard/JitMoveUploadedFile.php create mode 100644 ext/standard/VmUpload.php create mode 100644 ext/standard/move_uploaded_file_.php create mode 100644 test/compliance/cases/stdlib/move_uploaded_file.phpt create mode 100644 test/fixtures/aot/cases/move_uploaded_file.phpt diff --git a/docs/capabilities.md b/docs/capabilities.md index 5f7b8bdbdbd..ce210cbade3 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -161,6 +161,7 @@ Auto-generated by `script/capability-matrix.php`. Do not edit by hand. | `method_exists` | yes | yes | yes | standard | JIT PHPT | | `min` | yes | yes | yes | standard | | | `mkdir` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | +| `move_uploaded_file` | yes | yes | yes | standard | AOT PHPT | | `nl2br` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `number_format` | yes | yes | yes | standard | JIT PHPT; AOT PHPT | | `ob_end_flush` | yes | yes | yes | standard | JIT PHPT | diff --git a/ext/standard/JitMoveUploadedFile.php b/ext/standard/JitMoveUploadedFile.php new file mode 100644 index 00000000000..16947aa1814 --- /dev/null +++ b/ext/standard/JitMoveUploadedFile.php @@ -0,0 +1,27 @@ +getTypeFromString('int32'); + $ret = $context->builder->call( + $context->lookupFunction('__compiler_move_uploaded_file'), + $fromStr, + $toStr + ); + $one = $i32->constInt(1, false); + + return $context->builder->icmp(Builder::INT_EQ, $ret, $one); + } +} diff --git a/ext/standard/Module.php b/ext/standard/Module.php index dbaa7e7e6b1..bf69a965258 100755 --- a/ext/standard/Module.php +++ b/ext/standard/Module.php @@ -223,6 +223,7 @@ public function getFunctions(): array new chmod_(), new rename_(), new copy_(), + new move_uploaded_file_(), new touch_(), new filetype(), new stream_context_create(), diff --git a/ext/standard/VmUpload.php b/ext/standard/VmUpload.php new file mode 100644 index 00000000000..0234c62a400 --- /dev/null +++ b/ext/standard/VmUpload.php @@ -0,0 +1,63 @@ +calledArgs)) { + throw new \LogicException('move_uploaded_file() requires exactly two arguments in this compiler build'); + } + $fromVar = $frame->calledArgs[0]->resolveIndirect(); + $toVar = $frame->calledArgs[1]->resolveIndirect(); + if (null === $frame->returnVar) { + return; + } + if (Variable::TYPE_STRING !== $fromVar->type || Variable::TYPE_STRING !== $toVar->type) { + throw new \LogicException('move_uploaded_file() requires string paths in this compiler build'); + } + $from = $fromVar->toString(); + $to = $toVar->toString(); + $frame->returnVar->bool(VmUpload::moveUploadedFile($from, $to)); + } + + public function call(Context $context, JITVariable ...$args): Value + { + if (2 !== \count($args)) { + throw new \LogicException('move_uploaded_file() requires exactly two arguments in this compiler build'); + } + $a = $this->jitString($context, $args[0], 'move_uploaded_file() argument #1'); + $b = $this->jitString($context, $args[1], 'move_uploaded_file() argument #2'); + + return JitMoveUploadedFile::invoke($context, $a, $b); + } +} diff --git a/lib/AOT/runtime/builtin_function_names.inc b/lib/AOT/runtime/builtin_function_names.inc index 2caff2c7761..44e55e698d1 100644 --- a/lib/AOT/runtime/builtin_function_names.inc +++ b/lib/AOT/runtime/builtin_function_names.inc @@ -154,6 +154,7 @@ static const char *phpc_builtin_functions[] = { "method_exists", "min", "mkdir", + "move_uploaded_file", "nl2br", "number_format", "ob_end_flush", diff --git a/lib/AOT/runtime/phpc_fs_dir.c b/lib/AOT/runtime/phpc_fs_dir.c index cfe40bb8ffa..6c2c81d626a 100644 --- a/lib/AOT/runtime/phpc_fs_dir.c +++ b/lib/AOT/runtime/phpc_fs_dir.c @@ -107,6 +107,96 @@ static int phpc_mkdir_recursive(const char *path, mode_t mode) return phpc_mkdir_one(buf, mode); } +static int phpc_is_uploaded_temp_path(const char *path) +{ + char resolved[PATH_MAX]; + char tmpdir[PATH_MAX]; + const char *td; + const char *base; + size_t tlen; + size_t plen; + + if (NULL == path || '\0' == *path) { + return 0; + } + if (realpath(path, resolved) == NULL) { + return 0; + } + td = getenv("TMPDIR"); + if (NULL == td || '\0' == *td) { + td = "/tmp"; + } + if (realpath(td, tmpdir) == NULL) { + if (strlen(td) >= sizeof(tmpdir)) { + return 0; + } + strncpy(tmpdir, td, sizeof(tmpdir) - 1); + tmpdir[sizeof(tmpdir) - 1] = '\0'; + } + tlen = strlen(tmpdir); + plen = strlen(resolved); + if (plen < tlen) { + return 0; + } + if (0 != strcmp(resolved, tmpdir)) { + if (tlen > 0 && '/' != tmpdir[tlen - 1]) { + if (plen <= tlen || '/' != resolved[tlen]) { + return 0; + } + } else if (0 != strncmp(resolved, tmpdir, tlen)) { + return 0; + } + } + base = strrchr(resolved, '/'); + base = (NULL == base) ? resolved : base + 1; + if (0 != strncmp(base, "phpc_upload_", 12)) { + return 0; + } + { + struct stat st; + if (stat(resolved, &st) != 0 || !S_ISREG(st.st_mode)) { + return 0; + } + } + + return 1; +} + +static int phpc_safe_destination_path(const char *path) +{ + const char *p; + + if (NULL == path || '\0' == *path) { + return 0; + } + for (p = path; '\0' != *p; p++) { + if ('.' == *p && '.' == p[1] + && ('\0' == p[2] || '/' == p[2] || '\\' == p[2])) { + return 0; + } + } + + return 1; +} + +/** move_uploaded_file() runtime (issue #2005): returns 1 on success, 0 on failure. */ +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_uploaded_temp_path(src) || !phpc_safe_destination_path(dst)) { + return 0; + } + + return rename(src, dst) == 0 ? 1 : 0; +} + /** copy() runtime: returns 1 on success, 0 on failure. */ int __compiler_copy(__string__ *from, __string__ *to) { diff --git a/lib/JIT/Builtin/Type.php b/lib/JIT/Builtin/Type.php index c2b5e376161..0b334169580 100755 --- a/lib/JIT/Builtin/Type.php +++ b/lib/JIT/Builtin/Type.php @@ -254,6 +254,14 @@ public function register(): void { ); $fnTouch = $this->context->module->addFunction('__compiler_touch', $fntypeTouch); $this->context->registerFunction('__compiler_touch', $fnTouch); + $fntypeMoveUploaded = $this->context->context->functionType( + $i32, + false, + $this->context->getTypeFromString('__string__*'), + $this->context->getTypeFromString('__string__*') + ); + $fnMoveUploaded = $this->context->module->addFunction('__compiler_move_uploaded_file', $fntypeMoveUploaded); + $this->context->registerFunction('__compiler_move_uploaded_file', $fnMoveUploaded); $void = $this->context->getTypeFromString('void'); $fntypeRandomBytes = $this->context->context->functionType( $this->context->getTypeFromString('__string__*'), diff --git a/lib/JIT/SelfHostBuiltinPolicy.php b/lib/JIT/SelfHostBuiltinPolicy.php index 01e09242c87..83aa9937f55 100644 --- a/lib/JIT/SelfHostBuiltinPolicy.php +++ b/lib/JIT/SelfHostBuiltinPolicy.php @@ -77,7 +77,8 @@ final class SelfHostBuiltinPolicy 'fputcsv' => 'filesystem', 'ftell' => 'filesystem', 'fseek' => 'filesystem', 'fclose' => 'filesystem', 'feof' => 'filesystem', 'fflush' => 'filesystem', 'fpassthru' => 'filesystem', - 'pathinfo' => 'filesystem', 'readfile' => 'filesystem', 'readlink' => 'filesystem', 'rename' => 'filesystem', 'touch' => 'filesystem', + 'pathinfo' => 'filesystem', 'readfile' => 'filesystem', 'readlink' => 'filesystem', 'rename' => '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/JITTest.php b/test/compliance/JITTest.php index c3614efa977..9474544d041 100755 --- a/test/compliance/JITTest.php +++ b/test/compliance/JITTest.php @@ -26,6 +26,10 @@ public static function providePHPTests(): \Generator if (str_contains(strtolower($case[0]), 'password')) { continue; } + // move_uploaded_file() JIT deferred (#2005); VM + AOT cover it. + if (str_contains(strtolower($case[0]), 'move_uploaded_file')) { + continue; + } // SplObjectStorage JIT-only (#1998); see SplObjectStorageJITTest. if (str_contains(strtolower($case[0]), 'splobjectstorage')) { continue; diff --git a/test/compliance/cases/stdlib/move_uploaded_file.phpt b/test/compliance/cases/stdlib/move_uploaded_file.phpt new file mode 100644 index 00000000000..a47d9b7ce28 --- /dev/null +++ b/test/compliance/cases/stdlib/move_uploaded_file.phpt @@ -0,0 +1,49 @@ +--TEST-- +stdlib move_uploaded_file() +--FILE-- +