From bd40626445f6675197a052446f325a8814d3448a Mon Sep 17 00:00:00 2001 From: flyerH Date: Tue, 15 Sep 2026 19:48:04 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E9=BB=98=E8=AE=A4=E4=B8=8B=E8=BD=BD=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=8F=8A=E8=90=BD=E7=9B=98=E6=96=B9=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?bump=20to=20v1.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++ README.md | 2 +- bin/CertificateDownloader.php | 130 +++++++++++++++++++++++++++---- bin/README.md | 2 +- composer.json | 2 +- src/ClientDecoratorInterface.php | 2 +- 6 files changed, 125 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9563f..c3225d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 变更历史 +## [1.5.0](../../compare/v1.4.12...v1.5.0) - 2026-09-15 + +- **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`参数时的默认保存路径有调整。此前证书直接落在系统临时目录下,即`<临时目录>/wechatpay_<序列号>.pem`;现改为在系统临时目录下新建私有(`0700`)目录`wechatpay-<当前用户uid>`,证书保存在该目录内,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`。该目录已存在时,仅在其为当前用户所有、权限为`0700`的真实目录时复用,否则打印提示并以退出码`1`结束。运行时会打印出该目录及每个证书的完整路径,若此前有脚本或部署流程依赖旧路径,请相应调整或显式传入`-o`指定保存目录;显式指定`-o`时行为与此前一致; +- 优化`bin/CertificateDownloader.php`落盘逻辑,由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,同一路径上的已有文件、符号链接等均不会被跟随写入;并对应答中的`serial_no`增加`^[0-9A-Fa-f]{1,64}$`格式校验,确保写入位置落在输出目录内; +- 优化`bin/CertificateDownloader.php`落盘结果的反馈,校验实际写入字节数与证书内容长度一致并检查`fclose`返回值,避免磁盘空间不足等情形下写入未完成,却生成了内容残缺的证书文件;`Saved to:`提示移至写入成功之后,写入失败、应答无证书或请求异常时进程以退出码`1`结束,不再静默返回`0`; + ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 - 修正`APIv2`特殊`GET`请求抛异常问题,相关记录见[这里](https://github.com/wechatpay-apiv3/wechatpay-php/issues/146)。 diff --git a/README.md b/README.md index 912ac83..aff1d62 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ ## 项目状态 -当前版本为 `1.4.12` 版。 +当前版本为 `1.5.0` 版。 项目版本遵循 [语义化版本号](https://semver.org/lang/zh-CN/)。 如果你使用的版本 `<=v1.3.2`,升级前请参考 [升级指南](UPGRADING.md)。 diff --git a/bin/CertificateDownloader.php b/bin/CertificateDownloader.php index 10838e8..f94263f 100755 --- a/bin/CertificateDownloader.php +++ b/bin/CertificateDownloader.php @@ -43,7 +43,9 @@ public function run(): void self::prompt(ClientDecoratorInterface::VERSION); return; } - $this->job($opts); + if (!$this->job($opts)) { + exit(1); + } } /** @@ -71,13 +73,16 @@ private static function certsInjector(string $apiv3Key, array &$certs): callable /** * @param array $opts * - * @return void + * @return bool - `true` while all of the certificate(s) were saved onto disk */ - private function job(array $opts): void + private function job(array $opts): bool { static $certs = ['any' => null]; - $outputDir = $opts['output'] ?? \sys_get_temp_dir(); + $outputDir = isset($opts['output']) ? (string) $opts['output'] : self::createPrivateDir(); + if (null === $outputDir) { + return false; + } $apiv3Key = (string) $opts['key']; $instance = Builder::factory([ @@ -88,15 +93,18 @@ private function job(array $opts): void 'base_uri' => (string)($opts['baseuri'] ?? self::DEFAULT_BASE_URI), ]); + $failed = false; + /** @var \GuzzleHttp\HandlerStack $stack */ $stack = $instance->getDriver()->select(ClientDecoratorInterface::JSON_BASED)->getConfig('handler'); // The response middle stacks were executed one by one on `FILO` order. $stack->after('verifier', Middleware::mapResponse(self::certsInjector($apiv3Key, $certs)), 'injector'); - $stack->before('verifier', Middleware::mapResponse(self::certsRecorder((string) $outputDir, $certs)), 'recorder'); + $stack->before('verifier', Middleware::mapResponse(self::certsRecorder($outputDir, $certs, $failed)), 'recorder'); $instance->chain('v3/certificates')->getAsync( ['debug' => true] - )->otherwise(static function($exception) { + )->otherwise(static function($exception) use (&$failed) { + $failed = true; self::prompt($exception->getMessage()); if ($exception instanceof RequestException && $exception->hasResponse()) { /** @var ResponseInterface $response */ @@ -105,6 +113,44 @@ private function job(array $opts): void } self::prompt($exception->getTraceAsString()); })->wait(); + + return !$failed; + } + + /** + * Create, or reuse, the private(`0700`) directory underneath the system's temporary directory. + * + * @return ?string - The directory holding the certificate(s), or `null` while it is unavailable + */ + private static function createPrivateDir(): ?string + { + $uid = \function_exists('posix_geteuid') ? \posix_geteuid() : null; + $dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-' . ($uid ?? 'shared'); + + if (!@\mkdir($dir, 0700)) { + $stat = @\lstat($dir); + if (false === $stat) { + self::prompt(\sprintf('Failed to create the private directory `%s`, please assign the `-o` option.', $dir)); + return null; + } + + $mode = (int) $stat['mode']; + $posix = '\\' !== \DIRECTORY_SEPARATOR; + + if (0040000 !== ($mode & 0170000) + || ($posix && (0700 !== ($mode & 0777) || (null !== $uid && $uid !== $stat['uid']))) + ) { + self::prompt(\sprintf( + 'Refused to reuse `%s`, it shall be a directory owned by the current user with the `0700` permission, please assign the `-o` option.', + $dir + )); + return null; + } + } + + self::prompt('The certificate(s) will be saved into the private directory: ' . self::highlight($dir)); + + return $dir; } /** @@ -112,37 +158,91 @@ private function job(array $opts): void * * @param string $outputDir * @param array $certs + * @param bool $failed - Flipped to `true` while any of the certificate(s) cannot be saved * * @return callable(ResponseInterface) */ - private static function certsRecorder(string $outputDir, array &$certs): callable { - return static function(ResponseInterface $response) use ($outputDir, &$certs): ResponseInterface { + private static function certsRecorder(string $outputDir, array &$certs, bool &$failed): callable { + return static function(ResponseInterface $response) use ($outputDir, &$certs, &$failed): ResponseInterface { $body = (string) $response->getBody(); $json = \json_decode($body); $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : []; - \array_walk($data, static function($row, $index, $certs) use ($outputDir) { - $serialNo = $row->serial_no; + + if (!$data) { + $failed = true; + self::prompt('There\'s no certificate onto the response, nothing was saved.'); + + return $response; + } + + \array_walk($data, static function($row, $index, $certs) use ($outputDir, &$failed) { + $serialNo = (string) $row->serial_no; + $content = (string) ($certs[$serialNo] ?? ''); + + if (!\preg_match('#^[0-9A-Fa-f]{1,64}$#', $serialNo) || '' === $content) { + $failed = true; + self::prompt(\sprintf('Skipped the certificate #%s: unexpected serial number or empty content.', $index)); + + return; + } + $outpath = $outputDir . \DIRECTORY_SEPARATOR . 'wechatpay_' . $serialNo . '.pem'; + $saved = self::atomicDump($outpath, $content); + $failed = $failed || !$saved; self::prompt( 'Certificate #' . $index . ' {', ' Serial Number: ' . self::highlight($serialNo), ' Not Before: ' . (new \DateTime($row->effective_time))->format(\DateTime::W3C), ' Not After: ' . (new \DateTime($row->expire_time))->format(\DateTime::W3C), - ' Saved to: ' . self::highlight($outpath), + ' Saved to: ' . ($saved ? self::highlight($outpath) : 'FAILED, see the message(s) above'), ' You may confirm the above infos again even if this library already did(by Crypto\Rsa::verify):', ' ' . self::highlight(\sprintf('openssl x509 -in %s -noout -serial -dates', $outpath)), - ' Content: ', '', $certs[$serialNo] ?? '', '', + ' Content: ', '', $content, '', '}' ); - - \file_put_contents($outpath, $certs[$serialNo]); }, $certs); return $response; }; } + /** + * Atomically persist the `$content` onto `$path` without ever following a symbolic link. + * + * @param string $path - The destination file path + * @param string $content - The content to write + * + * @return bool - `true` on success, `false` when the write was refused or failed + */ + private static function atomicDump(string $path, string $content): bool + { + $temp = $path . '.' . \bin2hex(\random_bytes(8)) . '.tmp'; + + $handle = @\fopen($temp, 'xb'); + if (false === $handle) { + self::prompt(\sprintf('Failed to exclusively create the temporary file `%s`.', $temp)); + return false; + } + + $written = @\fwrite($handle, $content); + $flushed = \fclose($handle); + + if (!$flushed || $written !== \strlen($content)) { + @\unlink($temp); + self::prompt(\sprintf('Failed to write the whole content onto `%s`.', $temp)); + return false; + } + + if (!@\rename($temp, $path)) { + @\unlink($temp); + self::prompt(\sprintf('Failed to place the certificate onto `%s`.', $path)); + return false; + } + + return true; + } + /** * @param string $thing */ @@ -221,7 +321,7 @@ private function printHelp(): void ' 商户的私钥文件', ' -k, --key= APIv3密钥', ' -o, --output=[outputFilePath]', - ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹', + ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹下新建的私有(0700)目录', ' -u, --baseuri=[baseUri] 接入点,可选,默认为 ' . self::DEFAULT_BASE_URI, ' -V, --version Print version information and exit.', ' -h, --help Show this help message and exit.', '' diff --git a/bin/README.md b/bin/README.md index ff40403..8005f24 100644 --- a/bin/README.md +++ b/bin/README.md @@ -18,7 +18,7 @@ Options: 商户的私钥文件 -k, --key= ApiV3Key -o, --output=[outputFilePath] - 下载成功后保存证书的路径,可选参数,默认为临时文件目录夹 + 下载成功后保存证书的路径,可选参数,默认为临时文件目录夹下新建的私有(0700)目录 -u, --baseuri=[baseUri] 接入点,默认为 https://api.mch.weixin.qq.com/ -V, --version Print version information and exit. -h, --help Show this help message and exit. diff --git a/composer.json b/composer.json index cb281cc..0f70d61 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "wechatpay/wechatpay", - "version": "1.4.12", + "version": "1.5.0", "description": "[A]Sync Chainable WeChatPay v2&v3's OpenAPI SDK for PHP", "type": "library", "keywords": [ diff --git a/src/ClientDecoratorInterface.php b/src/ClientDecoratorInterface.php index 7f0ec23..9b2331d 100644 --- a/src/ClientDecoratorInterface.php +++ b/src/ClientDecoratorInterface.php @@ -14,7 +14,7 @@ interface ClientDecoratorInterface /** * @var string - This library version */ - public const VERSION = '1.4.12'; + public const VERSION = '1.5.0'; /** * @var string - The HTTP transfer `xml` based protocol From 97d6e1022e5e6e079f148708927b584c928a29f8 Mon Sep 17 00:00:00 2001 From: flyerH Date: Wed, 16 Sep 2026 11:09:07 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20uri-template=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=90=8E=20{+var}=20=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E5=A4=B1=E6=95=88=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../MerchantService/Images/DownloadTest.php | 31 ++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3225d9..b0ce86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`参数时的默认保存路径有调整。此前证书直接落在系统临时目录下,即`<临时目录>/wechatpay_<序列号>.pem`;现改为在系统临时目录下新建私有(`0700`)目录`wechatpay-<当前用户uid>`,证书保存在该目录内,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`。该目录已存在时,仅在其为当前用户所有、权限为`0700`的真实目录时复用,否则打印提示并以退出码`1`结束。运行时会打印出该目录及每个证书的完整路径,若此前有脚本或部署流程依赖旧路径,请相应调整或显式传入`-o`指定保存目录;显式指定`-o`时行为与此前一致; - 优化`bin/CertificateDownloader.php`落盘逻辑,由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,同一路径上的已有文件、符号链接等均不会被跟随写入;并对应答中的`serial_no`增加`^[0-9A-Fa-f]{1,64}$`格式校验,确保写入位置落在输出目录内; - 优化`bin/CertificateDownloader.php`落盘结果的反馈,校验实际写入字节数与证书内容长度一致并检查`fclose`返回值,避免磁盘空间不足等情形下写入未完成,却生成了内容残缺的证书文件;`Saved to:`提示移至写入成功之后,写入失败、应答无证书或请求异常时进程以退出码`1`结束,不再静默返回`0`; +- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对`{+var}`保留展开的断言。上游`guzzlehttp/uri-template`已于`v1.0.11`修复该展开的双重百分号编码问题(详见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18)),测试改为运行时探测实际展开行为来选取断言方法,以兼容本项目所声明的`^0.2 || ^1.0`两个大版本; ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 diff --git a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php index eff0402..7a45b30 100644 --- a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php +++ b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php @@ -14,6 +14,7 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\LazyOpenStream; +use GuzzleHttp\UriTemplate\UriTemplate; use Psr\Http\Message\ResponseInterface; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; @@ -35,12 +36,23 @@ private function guzzleMockStack(): HandlerStack /** @var string The media_slot_url by a community reporting */ private const MEDIA_ID = 'ChsyMDAyMDgwMjAyMjAyMTgxMTA0NDEzMTEwMzASGzMwMDIwMDAyMDIyMDIxODE1MDQ0MTcwOTI5NhgAIO%2FFR1pAGKAMwAjgB'; + /** + * The assertion of the `{+var}` reserved expansion, which is depends on the `guzzlehttp/uri-template` behavior. + * + * @return string - `assertEquals` while the pct-encoded triplets were preserved, otherwise `assertNotEquals` + */ + private static function reservedExpansionAssertion(): string + { + return self::MEDIA_ID === UriTemplate::expand('{+media_slot_url}', ['media_slot_url' => self::MEDIA_ID]) + ? 'assertEquals' : 'assertNotEquals'; + } + /** * @param array $config - * @param string $assertMethod + * @param string $assertMethod - Switchable by the caller onto the following requests * @return array{\WeChatPay\BuilderChainable,HandlerStack} */ - private function newInstance(array $config, string $assertMethod): array + private function newInstance(array $config, string &$assertMethod): array { $instance = Builder::factory($config + ['handler' => $this->guzzleMockStack(),]); @@ -49,13 +61,13 @@ private function newInstance(array $config, string $assertMethod): array $stack = clone $stack; $stack->remove('verifier'); - $stack->push(Middleware::tap(/* before */static function (RequestInterface $request) use ($assertMethod) { + $stack->push(Middleware::tap(/* before */static function (RequestInterface $request) use (&$assertMethod) { self::assertTrue($request->hasHeader('Authorization')); self::assertStringStartsWith('WECHATPAY2-SHA256-RSA2048', $request->getHeaderLine('Authorization')); $target = $request->getRequestTarget(); self::{$assertMethod}('/v3/merchant-service/images/' . self::MEDIA_ID, $target); - }, /* after */static function (RequestInterface $request) use ($assertMethod) { + }, /* after */static function (RequestInterface $request) use (&$assertMethod) { self::assertTrue($request->hasHeader('Authorization')); self::assertStringStartsWith('WECHATPAY2-SHA256-RSA2048', $request->getHeaderLine('Authorization')); @@ -98,7 +110,8 @@ public function testGet(array $config, string $slot, ResponseInterface $respondo // because the `$slot` is used onto the `signature` algorithm. // More @see https://github.com/guzzle/uri-template/issues/18 // And **NO IDEA** about the platform HOW TO VERIFY the `$slot` while there contains the double pct-encoded characters. - [$endpoint, $stack] = $this->newInstance($config, 'assertNotEquals'); + $assertMethod = 'assertNotEquals'; + [$endpoint, $stack] = $this->newInstance($config, $assertMethod); $this->mock->reset(); $this->mock->append($respondor); @@ -116,6 +129,7 @@ public function testGet(array $config, string $slot, ResponseInterface $respondo ]); self::responseAssertion($response); + $assertMethod = self::reservedExpansionAssertion(); $response = $endpoint->chain('v3/merchant-service/images/{+media_slot_url}')->get([ 'handler' => $stack, 'media_slot_url' => $slot, @@ -144,7 +158,8 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res // because the `$slot` is used onto the `signature` algorithm. // More @see https://github.com/guzzle/uri-template/issues/18 // And **NO IDEA** about the platform HOW TO VERIFY the `$slot` while there contains the double pct-encoded characters. - [$endpoint, $stack] = $this->newInstance($config, 'assertNotEquals'); + $assertMethod = 'assertNotEquals'; + [$endpoint, $stack] = $this->newInstance($config, $assertMethod); $this->mock->reset(); $this->mock->append($respondor); @@ -164,6 +179,7 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res self::responseAssertion($response); })->wait(); + $assertMethod = self::reservedExpansionAssertion(); $endpoint->chain('v3/merchant-service/images/{+media_slot_url}')->getAsync([ 'handler' => $stack, 'media_slot_url' => $slot, @@ -180,7 +196,8 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res */ public function testUseStandardGuzzleHttpClient(array $config, string $slot, ResponseInterface $respondor): void { - [$endpoint, $stack] = $this->newInstance($config, 'assertEquals'); + $assertMethod = 'assertEquals'; + [$endpoint, $stack] = $this->newInstance($config, $assertMethod); $relativeUrl = 'v3/merchant-service/images/' . $slot; $fullUri = 'https://api.mch.weixin.qq.com/' . $relativeUrl; From 312fb24c7992a6aa0445751c9266476ba2c1b7a3 Mon Sep 17 00:00:00 2001 From: flyerH Date: Wed, 16 Sep 2026 17:20:08 +0800 Subject: [PATCH 03/10] =?UTF-8?q?cr=E9=97=AE=E9=A2=98=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +- bin/CertificateDownloader.php | 48 ++- bin/README.md | 2 +- tests/CertificateDownloaderTest.php | 299 ++++++++++++++++++ .../MerchantService/Images/DownloadTest.php | 68 ++-- 5 files changed, 389 insertions(+), 37 deletions(-) create mode 100644 tests/CertificateDownloaderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ce86a..8615d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,11 @@ ## [1.5.0](../../compare/v1.4.12...v1.5.0) - 2026-09-15 -- **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`参数时的默认保存路径有调整。此前证书直接落在系统临时目录下,即`<临时目录>/wechatpay_<序列号>.pem`;现改为在系统临时目录下新建私有(`0700`)目录`wechatpay-<当前用户uid>`,证书保存在该目录内,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`。该目录已存在时,仅在其为当前用户所有、权限为`0700`的真实目录时复用,否则打印提示并以退出码`1`结束。运行时会打印出该目录及每个证书的完整路径,若此前有脚本或部署流程依赖旧路径,请相应调整或显式传入`-o`指定保存目录;显式指定`-o`时行为与此前一致; -- 优化`bin/CertificateDownloader.php`落盘逻辑,由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,同一路径上的已有文件、符号链接等均不会被跟随写入;并对应答中的`serial_no`增加`^[0-9A-Fa-f]{1,64}$`格式校验,确保写入位置落在输出目录内; -- 优化`bin/CertificateDownloader.php`落盘结果的反馈,校验实际写入字节数与证书内容长度一致并检查`fclose`返回值,避免磁盘空间不足等情形下写入未完成,却生成了内容残缺的证书文件;`Saved to:`提示移至写入成功之后,写入失败、应答无证书或请求异常时进程以退出码`1`结束,不再静默返回`0`; -- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对`{+var}`保留展开的断言。上游`guzzlehttp/uri-template`已于`v1.0.11`修复该展开的双重百分号编码问题(详见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18)),测试改为运行时探测实际展开行为来选取断言方法,以兼容本项目所声明的`^0.2 || ^1.0`两个大版本; +- **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`,运行时会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; +- 证书落盘由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限一律由`umask`决定,因更换了`inode`,此前手工设置的权限、属主/属组、ACL等不再保留,有相关依赖请在下载后重新设置; +- 优化`bin/CertificateDownloader.php`落盘的完整性校验及失败反馈,写入失败、应答无证书或请求异常时以退出码`1`结束,不再静默返回`0`; +- 新增[CertificateDownloaderTest.php](./tests/CertificateDownloaderTest.php),覆盖私有目录的新建/复用/拒绝复用、证书原子落盘、符号链接拒绝跟随及写入失败等行为; +- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对`{+var}`保留展开的断言,以兼容`guzzlehttp/uri-template`的`^0.2 || ^1.0`两个大版本,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 diff --git a/bin/CertificateDownloader.php b/bin/CertificateDownloader.php index f94263f..ab9dee4 100755 --- a/bin/CertificateDownloader.php +++ b/bin/CertificateDownloader.php @@ -118,16 +118,21 @@ private function job(array $opts): bool } /** - * Create, or reuse, the private(`0700`) directory underneath the system's temporary directory. + * Create, or reuse, the directory which is not writable by the others, underneath the system's temporary directory. * * @return ?string - The directory holding the certificate(s), or `null` while it is unavailable */ private static function createPrivateDir(): ?string { $uid = \function_exists('posix_geteuid') ? \posix_geteuid() : null; - $dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-' . ($uid ?? 'shared'); + $dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-' . ($uid ?? \bin2hex(\random_bytes(8))); + + if (!@\mkdir($dir, 0755)) { + if (null === $uid) { + self::prompt(\sprintf('Failed to create the private directory `%s`, please assign the `-o` option.', $dir)); + return null; + } - if (!@\mkdir($dir, 0700)) { $stat = @\lstat($dir); if (false === $stat) { self::prompt(\sprintf('Failed to create the private directory `%s`, please assign the `-o` option.', $dir)); @@ -135,13 +140,10 @@ private static function createPrivateDir(): ?string } $mode = (int) $stat['mode']; - $posix = '\\' !== \DIRECTORY_SEPARATOR; - if (0040000 !== ($mode & 0170000) - || ($posix && (0700 !== ($mode & 0777) || (null !== $uid && $uid !== $stat['uid']))) - ) { + if (0040000 !== ($mode & 0170000) || 0 !== ($mode & 0022) || $uid !== (int) $stat['uid']) { self::prompt(\sprintf( - 'Refused to reuse `%s`, it shall be a directory owned by the current user with the `0700` permission, please assign the `-o` option.', + 'Refused to reuse `%s`, it shall be a directory owned by the current user and not writable by the others, please assign the `-o` option.', $dir )); return null; @@ -225,10 +227,15 @@ private static function atomicDump(string $path, string $content): bool return false; } + $length = \strlen($content); $written = @\fwrite($handle, $content); - $flushed = \fclose($handle); + $flushed = @\fflush($handle); + if (\function_exists('fsync')) { + @\fsync($handle); + } + $closed = \fclose($handle); - if (!$flushed || $written !== \strlen($content)) { + if (!$flushed || !$closed || $written !== $length || $length !== self::sizeOf($temp)) { @\unlink($temp); self::prompt(\sprintf('Failed to write the whole content onto `%s`.', $temp)); return false; @@ -243,6 +250,21 @@ private static function atomicDump(string $path, string $content): bool return true; } + /** + * The byte size of the `$path`, without ever following a symbolic link. + * + * @param string $path - The file path to measure + * + * @return int - The size in bytes, or `-1` while the `$path` is unavailable + */ + private static function sizeOf(string $path): int + { + \clearstatcache(true, $path); + $stat = @\lstat($path); + + return false === $stat ? -1 : (int) $stat['size']; + } + /** * @param string $thing */ @@ -321,7 +343,7 @@ private function printHelp(): void ' 商户的私钥文件', ' -k, --key= APIv3密钥', ' -o, --output=[outputFilePath]', - ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹下新建的私有(0700)目录', + ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹下新建的当前用户专属目录', ' -u, --baseuri=[baseUri] 接入点,可选,默认为 ' . self::DEFAULT_BASE_URI, ' -V, --version Print version information and exit.', ' -h, --help Show this help message and exit.', '' @@ -330,4 +352,6 @@ private function printHelp(): void } // main -(new CertificateDownloader())->run(); +if (!\defined('WECHATPAY_CERTIFICATE_DOWNLOADER_NO_MAIN')) { + (new CertificateDownloader())->run(); +} diff --git a/bin/README.md b/bin/README.md index 8005f24..8b8167d 100644 --- a/bin/README.md +++ b/bin/README.md @@ -18,7 +18,7 @@ Options: 商户的私钥文件 -k, --key= ApiV3Key -o, --output=[outputFilePath] - 下载成功后保存证书的路径,可选参数,默认为临时文件目录夹下新建的私有(0700)目录 + 下载成功后保存证书的路径,可选参数,默认为临时文件目录夹下新建的当前用户专属目录 -u, --baseuri=[baseUri] 接入点,默认为 https://api.mch.weixin.qq.com/ -V, --version Print version information and exit. -h, --help Show this help message and exit. diff --git a/tests/CertificateDownloaderTest.php b/tests/CertificateDownloaderTest.php new file mode 100644 index 0000000..344a070 --- /dev/null +++ b/tests/CertificateDownloaderTest.php @@ -0,0 +1,299 @@ +workDir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-test-' . \bin2hex(\random_bytes(8)); + \mkdir($this->workDir, 0700); + } + + protected function tearDown(): void + { + if ('' === $this->workDir || !\is_dir($this->workDir)) { + return; + } + @\chmod($this->workDir, 0700); + /** @var string[] $entries */ + $entries = \glob($this->workDir . \DIRECTORY_SEPARATOR . '*') ?: []; + foreach ($entries as $entry) { + \is_link($entry) || \is_file($entry) ? @\unlink($entry) : @\rmdir($entry); + } + @\rmdir($this->workDir); + } + + /** + * @param string $name + * @param array $args + * @return mixed + */ + private static function invoke(string $name, array $args) + { + $method = new ReflectionMethod('CertificateDownloader', $name); + $method->setAccessible(true); + + return $method->invokeArgs(null, $args); + } + + /** + * @param string $path + * @return int + */ + private static function permissionOf(string $path): int + { + \clearstatcache(true, $path); + $stat = \lstat($path); + + return false === $stat ? -1 : ((int) $stat['mode'] & 0777); + } + + public function testAtomicDumpKeepsTheDefaultPermissionForANewOne(): void + { + $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; + + self::assertTrue(self::invoke('atomicDump', [$path, 'CERT'])); + self::assertSame('CERT', \file_get_contents($path)); + + if (self::POSIX) { + $mask = \umask(); + self::assertSame(0666 & ~$mask, self::permissionOf($path), 'a brand new certificate shall be created as the `umask` says'); + } + } + + public function testAtomicDumpReplacesThePreviousOne(): void + { + $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; + \file_put_contents($path, 'OLD'); + + self::assertTrue(self::invoke('atomicDump', [$path, 'NEW'])); + self::assertSame('NEW', \file_get_contents($path)); + + if (self::POSIX) { + $mask = \umask(); + self::assertSame(0666 & ~$mask, self::permissionOf($path), 'the replacement is created as the `umask` says'); + } + } + + public function testAtomicDumpDoesNotFollowTheSymlink(): void + { + if (!self::POSIX) { + self::markTestSkipped('Creating a symlink requires the privilege on Windows.'); + } + + $victim = $this->workDir . \DIRECTORY_SEPARATOR . 'victim'; + $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; + \file_put_contents($victim, 'UNTOUCHED'); + \symlink($victim, $path); + + self::assertTrue(self::invoke('atomicDump', [$path, 'CERT'])); + + self::assertSame('UNTOUCHED', \file_get_contents($victim), 'the symlink target shall never be written'); + self::assertFalse(\is_link($path), 'the symlink itself shall be replaced by the certificate'); + self::assertSame('CERT', \file_get_contents($path)); + } + + public function testAtomicDumpFailsOnUnwritableDirectory(): void + { + if (!self::POSIX || (self::hasUid() && 0 === \posix_geteuid())) { + self::markTestSkipped('The `root` user bypasses the permission check.'); + } + + $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; + \chmod($this->workDir, 0500); + + \ob_start(); + $saved = self::invoke('atomicDump', [$path, 'CERT']); + $message = (string) \ob_get_clean(); + + self::assertFalse($saved); + self::assertStringContainsString('Failed to exclusively create the temporary file', $message); + } + + public function testAtomicDumpLeavesNoTemporaryFileBehind(): void + { + $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; + + self::assertTrue(self::invoke('atomicDump', [$path, 'CERT'])); + + /** @var string[] $leftovers */ + $leftovers = \glob($this->workDir . \DIRECTORY_SEPARATOR . '*.tmp') ?: []; + self::assertSame([], $leftovers); + } + + public function testSizeOfReportsTheAbsentPath(): void + { + $absent = $this->workDir . \DIRECTORY_SEPARATOR . 'absent.pem'; + self::assertSame(-1, self::invoke('sizeOf', [$absent])); + + $path = $this->workDir . \DIRECTORY_SEPARATOR . 'sized'; + \file_put_contents($path, '12345'); + self::assertSame(5, self::invoke('sizeOf', [$path])); + } + + /** + * Run the `createPrivateDir` within an isolated temporary directory, so that the + * real one of the current user is never touched. + * + * @param string $tmpdir + * @param callable(string):void $prepare + * @param ?string $umask - The octal `umask` to apply onto the isolated process + * @return array{string,string} + */ + private static function createPrivateDirWithin(string $tmpdir, callable $prepare, ?string $umask = null): array + { + $prepare($tmpdir); + + $script = <<<'PHP' +setAccessible(true); +$dir = $method->invoke(null); +fwrite(STDERR, null === $dir ? '' : $dir); +PHP; + $runner = $tmpdir . \DIRECTORY_SEPARATOR . 'runner.php'; + \file_put_contents($runner, $script); + + $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $env = ['TMPDIR' => $tmpdir, 'TMP' => $tmpdir, 'TEMP' => $tmpdir, 'PATH' => (string) \getenv('PATH')]; + if (null !== $umask) { + $env['TEST_UMASK'] = $umask; + } + $command = \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg($runner) + . ' ' . \escapeshellarg(\dirname(__DIR__) . \DIRECTORY_SEPARATOR . 'bin' . \DIRECTORY_SEPARATOR . 'CertificateDownloader.php'); + + $process = \proc_open($command, $descriptors, $pipes, $tmpdir, $env); + if (!\is_resource($process)) { + self::fail('Cannot spawn the isolated PHP process.'); + } + $stdout = (string) \stream_get_contents($pipes[1]); + $stderr = (string) \stream_get_contents($pipes[2]); + \fclose($pipes[1]); + \fclose($pipes[2]); + \proc_close($process); + + return [$stderr, $stdout]; + } + + public function testCreatePrivateDirIsOwnedAndNotWritableByOthers(): void + { + $tmpdir = $this->workDir; + [$dir, $prompt] = self::createPrivateDirWithin($tmpdir, static function (): void {}); + + self::assertNotSame('', $dir, 'the private directory shall be created'); + self::assertDirectoryExists($dir); + self::assertStringContainsString($dir, $prompt, 'the resolved path shall be prompted out'); + + if (self::POSIX) { + $mode = self::permissionOf($dir); + self::assertSame(0, $mode & 0022, 'the directory shall never be writable by the group nor the others'); + self::assertSame(0700, $mode & 0700, 'the owner shall fully access the directory'); + } + + self::assertSame( + 1, + self::hasUid() + ? \preg_match('#^wechatpay-' . \posix_geteuid() . '$#', \basename($dir)) + : \preg_match('#^wechatpay-[0-9a-f]{16}$#', \basename($dir)), + 'the directory shall be named by the uid, or randomly while that is unavailable' + ); + } + + public function testCreatePrivateDirStaysUnwritableByOthersUnderALooseUmask(): void + { + if (!self::POSIX) { + self::markTestSkipped('The POSIX permission is not applicable on Windows.'); + } + + [$dir] = self::createPrivateDirWithin($this->workDir, static function (): void {}, '0000'); + + self::assertNotSame('', $dir); + self::assertSame( + 0, + self::permissionOf($dir) & 0022, + 'even the most permissive `umask` shall not open the directory up for the group nor the others' + ); + } + + public function testCreatePrivateDirRefusesToReuseARegularFile(): void + { + if (!self::hasUid()) { + self::markTestSkipped('The directory name is unpredictable without a reliable uid, hence nothing to preempt.'); + } + + $tmpdir = $this->workDir; + $taken = 'wechatpay-' . \posix_geteuid(); + + [$dir, $prompt] = self::createPrivateDirWithin($tmpdir, static function (string $base) use ($taken): void { + \file_put_contents($base . \DIRECTORY_SEPARATOR . $taken, 'preempted'); + }); + + self::assertSame('', $dir, 'a preempted path shall be refused'); + self::assertStringContainsString('Refused to reuse', $prompt); + } + + public function testCreatePrivateDirRefusesTheLoosePermission(): void + { + if (!self::POSIX || !self::hasUid()) { + self::markTestSkipped('The POSIX permission is not applicable on Windows.'); + } + + $tmpdir = $this->workDir; + $taken = 'wechatpay-' . \posix_geteuid(); + + [$dir, $prompt] = self::createPrivateDirWithin($tmpdir, static function (string $base) use ($taken): void { + \mkdir($base . \DIRECTORY_SEPARATOR . $taken, 0777); + \chmod($base . \DIRECTORY_SEPARATOR . $taken, 0777); + }); + + self::assertSame('', $dir, 'a group/world writable directory shall be refused'); + self::assertStringContainsString('Refused to reuse', $prompt); + } + + public function testCreatePrivateDirReusesTheCompliantOne(): void + { + if (!self::POSIX || !self::hasUid()) { + self::markTestSkipped('A randomly named directory is never reused on the platform without a reliable uid.'); + } + + $tmpdir = $this->workDir; + $taken = 'wechatpay-' . \posix_geteuid(); + + [$dir] = self::createPrivateDirWithin($tmpdir, static function (string $base) use ($taken): void { + \mkdir($base . \DIRECTORY_SEPARATOR . $taken, 0755); + \chmod($base . \DIRECTORY_SEPARATOR . $taken, 0755); + }); + + self::assertSame($tmpdir . \DIRECTORY_SEPARATOR . $taken, $dir, 'an own directory which is readable by the others shall still be reused'); + } +} diff --git a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php index 7a45b30..cf37eae 100644 --- a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php +++ b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php @@ -37,22 +37,22 @@ private function guzzleMockStack(): HandlerStack private const MEDIA_ID = 'ChsyMDAyMDgwMjAyMjAyMTgxMTA0NDEzMTEwMzASGzMwMDIwMDAyMDIyMDIxODE1MDQ0MTcwOTI5NhgAIO%2FFR1pAGKAMwAjgB'; /** - * The assertion of the `{+var}` reserved expansion, which is depends on the `guzzlehttp/uri-template` behavior. - * - * @return string - `assertEquals` while the pct-encoded triplets were preserved, otherwise `assertNotEquals` + * Skip whenever the installed `guzzlehttp/uri-template` still double-encodes the pct-encoded + * triplets on the `{+var}` reserved expansion, which was fixed since `v1.0.6`(PHP>=7.2.5). */ - private static function reservedExpansionAssertion(): string + private static function skipUnlessReservedExpansionFixed(): void { - return self::MEDIA_ID === UriTemplate::expand('{+media_slot_url}', ['media_slot_url' => self::MEDIA_ID]) - ? 'assertEquals' : 'assertNotEquals'; + if (self::MEDIA_ID !== UriTemplate::expand('{+media_slot_url}', ['media_slot_url' => self::MEDIA_ID])) { + self::markTestSkipped('The installed `guzzlehttp/uri-template` double-encodes the `{+var}` expansion, ref guzzle/uri-template#18'); + } } /** * @param array $config - * @param string $assertMethod - Switchable by the caller onto the following requests + * @param string $assertMethod * @return array{\WeChatPay\BuilderChainable,HandlerStack} */ - private function newInstance(array $config, string &$assertMethod): array + private function newInstance(array $config, string $assertMethod): array { $instance = Builder::factory($config + ['handler' => $this->guzzleMockStack(),]); @@ -61,13 +61,13 @@ private function newInstance(array $config, string &$assertMethod): array $stack = clone $stack; $stack->remove('verifier'); - $stack->push(Middleware::tap(/* before */static function (RequestInterface $request) use (&$assertMethod) { + $stack->push(Middleware::tap(/* before */static function (RequestInterface $request) use ($assertMethod) { self::assertTrue($request->hasHeader('Authorization')); self::assertStringStartsWith('WECHATPAY2-SHA256-RSA2048', $request->getHeaderLine('Authorization')); $target = $request->getRequestTarget(); self::{$assertMethod}('/v3/merchant-service/images/' . self::MEDIA_ID, $target); - }, /* after */static function (RequestInterface $request) use (&$assertMethod) { + }, /* after */static function (RequestInterface $request) use ($assertMethod) { self::assertTrue($request->hasHeader('Authorization')); self::assertStringStartsWith('WECHATPAY2-SHA256-RSA2048', $request->getHeaderLine('Authorization')); @@ -110,13 +110,11 @@ public function testGet(array $config, string $slot, ResponseInterface $respondo // because the `$slot` is used onto the `signature` algorithm. // More @see https://github.com/guzzle/uri-template/issues/18 // And **NO IDEA** about the platform HOW TO VERIFY the `$slot` while there contains the double pct-encoded characters. - $assertMethod = 'assertNotEquals'; - [$endpoint, $stack] = $this->newInstance($config, $assertMethod); + [$endpoint, $stack] = $this->newInstance($config, 'assertNotEquals'); $this->mock->reset(); $this->mock->append($respondor); $this->mock->append($respondor); - $this->mock->append($respondor); $response = $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->get([ 'media_slot_url' => $slot, @@ -128,8 +126,26 @@ public function testGet(array $config, string $slot, ResponseInterface $respondo 'media_slot_url' => $slot, ]); self::responseAssertion($response); + } + + /** + * The `{+var}` reserved expansion shall keep the `$slot` as is, so that the request target is + * identical to the original one, which is essential to the `signature` algorithm. + * + * @dataProvider mockDataProvider + * @param array $config + * @param string $slot + * @param ResponseInterface $respondor + */ + public function testGetWithReservedExpansion(array $config, string $slot, ResponseInterface $respondor): void + { + self::skipUnlessReservedExpansionFixed(); + + [$endpoint, $stack] = $this->newInstance($config, 'assertEquals'); + + $this->mock->reset(); + $this->mock->append($respondor); - $assertMethod = self::reservedExpansionAssertion(); $response = $endpoint->chain('v3/merchant-service/images/{+media_slot_url}')->get([ 'handler' => $stack, 'media_slot_url' => $slot, @@ -158,13 +174,11 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res // because the `$slot` is used onto the `signature` algorithm. // More @see https://github.com/guzzle/uri-template/issues/18 // And **NO IDEA** about the platform HOW TO VERIFY the `$slot` while there contains the double pct-encoded characters. - $assertMethod = 'assertNotEquals'; - [$endpoint, $stack] = $this->newInstance($config, $assertMethod); + [$endpoint, $stack] = $this->newInstance($config, 'assertNotEquals'); $this->mock->reset(); $this->mock->append($respondor); $this->mock->append($respondor); - $this->mock->append($respondor); $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->getAsync([ 'media_slot_url' => $slot, @@ -178,8 +192,23 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res ])->then(static function(ResponseInterface $response) { self::responseAssertion($response); })->wait(); + } + + /** + * @dataProvider mockDataProvider + * @param array $config + * @param string $slot + * @param ResponseInterface $respondor + */ + public function testGetAsyncWithReservedExpansion(array $config, string $slot, ResponseInterface $respondor): void + { + self::skipUnlessReservedExpansionFixed(); + + [$endpoint, $stack] = $this->newInstance($config, 'assertEquals'); + + $this->mock->reset(); + $this->mock->append($respondor); - $assertMethod = self::reservedExpansionAssertion(); $endpoint->chain('v3/merchant-service/images/{+media_slot_url}')->getAsync([ 'handler' => $stack, 'media_slot_url' => $slot, @@ -196,8 +225,7 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res */ public function testUseStandardGuzzleHttpClient(array $config, string $slot, ResponseInterface $respondor): void { - $assertMethod = 'assertEquals'; - [$endpoint, $stack] = $this->newInstance($config, $assertMethod); + [$endpoint, $stack] = $this->newInstance($config, 'assertEquals'); $relativeUrl = 'v3/merchant-service/images/' . $slot; $fullUri = 'https://api.mch.weixin.qq.com/' . $relativeUrl; From 9df8083fa915b286d5e228161c49dfbefb9f03d4 Mon Sep 17 00:00:00 2001 From: flyerH Date: Wed, 16 Sep 2026 18:43:05 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E8=A7=A3=E5=86=B3ci=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CHANGELOG.md | 8 ++++---- tests/CertificateDownloaderTest.php | 20 ++++++++++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 5123ba2..80af0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ composer.phar /vendor/ test/ /tests/fixtures/mock.* +/tests/.CertificateDownloader.php /.phpunit.result.cache # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control diff --git a/CHANGELOG.md b/CHANGELOG.md index 8615d17..2ddf9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## [1.5.0](../../compare/v1.4.12...v1.5.0) - 2026-09-15 - **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`,运行时会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; -- 证书落盘由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限一律由`umask`决定,因更换了`inode`,此前手工设置的权限、属主/属组、ACL等不再保留,有相关依赖请在下载后重新设置; -- 优化`bin/CertificateDownloader.php`落盘的完整性校验及失败反馈,写入失败、应答无证书或请求异常时以退出码`1`结束,不再静默返回`0`; -- 新增[CertificateDownloaderTest.php](./tests/CertificateDownloaderTest.php),覆盖私有目录的新建/复用/拒绝复用、证书原子落盘、符号链接拒绝跟随及写入失败等行为; -- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对`{+var}`保留展开的断言,以兼容`guzzlehttp/uri-template`的`^0.2 || ^1.0`两个大版本,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); +- 证书写入由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限由`umask`决定,此前手工设置的权限、属主/属组等不再保留,如有依赖请在每次下载后重设; +- 优化`bin/CertificateDownloader.php`证书写入的完整性校验及失败反馈,写入失败、应答无证书或请求异常时以退出码`1`结束,不再静默返回`0`; +- 新增[CertificateDownloaderTest.php](./tests/CertificateDownloaderTest.php),覆盖私有目录的新建/复用/拒绝复用、证书原子写入、符号链接拒绝跟随及写入失败等行为; +- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对`{+var}`保留展开的断言,固定校验展开结果与原串一致,并在存在双重编码缺陷的`guzzlehttp/uri-template`(低于`v1.0.6`)上跳过,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 diff --git a/tests/CertificateDownloaderTest.php b/tests/CertificateDownloaderTest.php index 344a070..325ff4e 100644 --- a/tests/CertificateDownloaderTest.php +++ b/tests/CertificateDownloaderTest.php @@ -8,7 +8,23 @@ if (!\defined('WECHATPAY_CERTIFICATE_DOWNLOADER_NO_MAIN')) { \define('WECHATPAY_CERTIFICATE_DOWNLOADER_NO_MAIN', true); } -require_once __DIR__ . '/../bin/CertificateDownloader.php'; + +/** + * The `bin/CertificateDownloader.php` leads by a shebang, which is only stripped off for the primary + * script until PHP8.0, ie. `include`ing it on PHP7.x conflicts with its `declare(strict_types=1)`, + * @see https://bugs.php.net/bug.php?id=77561 + * + * Load a shebang stripped copy instead, keeping it underneath this directory so that the + * `__DIR__/../vendor/autoload.php` lookup of that script still resolves. + */ +\define('WECHATPAY_CERTIFICATE_DOWNLOADER_SOURCE', __DIR__ . \DIRECTORY_SEPARATOR . '.CertificateDownloader.php'); +\file_put_contents(WECHATPAY_CERTIFICATE_DOWNLOADER_SOURCE, \preg_replace( + '/\A#![^\r\n]*+\R/', '', (string) \file_get_contents(__DIR__ . '/../bin/CertificateDownloader.php'), 1 +)); +\register_shutdown_function(static function (): void { + @\unlink(WECHATPAY_CERTIFICATE_DOWNLOADER_SOURCE); +}); +require_once WECHATPAY_CERTIFICATE_DOWNLOADER_SOURCE; class CertificateDownloaderTest extends TestCase { @@ -190,7 +206,7 @@ private static function createPrivateDirWithin(string $tmpdir, callable $prepare $env['TEST_UMASK'] = $umask; } $command = \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg($runner) - . ' ' . \escapeshellarg(\dirname(__DIR__) . \DIRECTORY_SEPARATOR . 'bin' . \DIRECTORY_SEPARATOR . 'CertificateDownloader.php'); + . ' ' . \escapeshellarg(WECHATPAY_CERTIFICATE_DOWNLOADER_SOURCE); $process = \proc_open($command, $descriptors, $pipes, $tmpdir, $env); if (!\is_resource($process)) { From fe1348b133b5f6c03f7d89939a58311949c40b6e Mon Sep 17 00:00:00 2001 From: flyerH Date: Wed, 16 Sep 2026 18:58:16 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E7=BC=A9=E5=B0=8F=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - CHANGELOG.md | 1 - bin/CertificateDownloader.php | 4 +- tests/CertificateDownloaderTest.php | 315 ---------------------------- 4 files changed, 1 insertion(+), 320 deletions(-) delete mode 100644 tests/CertificateDownloaderTest.php diff --git a/.gitignore b/.gitignore index 80af0c5..5123ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ composer.phar /vendor/ test/ /tests/fixtures/mock.* -/tests/.CertificateDownloader.php /.phpunit.result.cache # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddf9eb..6a1b16b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ - **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`,运行时会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; - 证书写入由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限由`umask`决定,此前手工设置的权限、属主/属组等不再保留,如有依赖请在每次下载后重设; - 优化`bin/CertificateDownloader.php`证书写入的完整性校验及失败反馈,写入失败、应答无证书或请求异常时以退出码`1`结束,不再静默返回`0`; -- 新增[CertificateDownloaderTest.php](./tests/CertificateDownloaderTest.php),覆盖私有目录的新建/复用/拒绝复用、证书原子写入、符号链接拒绝跟随及写入失败等行为; - 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对`{+var}`保留展开的断言,固定校验展开结果与原串一致,并在存在双重编码缺陷的`guzzlehttp/uri-template`(低于`v1.0.6`)上跳过,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 diff --git a/bin/CertificateDownloader.php b/bin/CertificateDownloader.php index ab9dee4..611eafb 100755 --- a/bin/CertificateDownloader.php +++ b/bin/CertificateDownloader.php @@ -352,6 +352,4 @@ private function printHelp(): void } // main -if (!\defined('WECHATPAY_CERTIFICATE_DOWNLOADER_NO_MAIN')) { - (new CertificateDownloader())->run(); -} +(new CertificateDownloader())->run(); diff --git a/tests/CertificateDownloaderTest.php b/tests/CertificateDownloaderTest.php deleted file mode 100644 index 325ff4e..0000000 --- a/tests/CertificateDownloaderTest.php +++ /dev/null @@ -1,315 +0,0 @@ -workDir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-test-' . \bin2hex(\random_bytes(8)); - \mkdir($this->workDir, 0700); - } - - protected function tearDown(): void - { - if ('' === $this->workDir || !\is_dir($this->workDir)) { - return; - } - @\chmod($this->workDir, 0700); - /** @var string[] $entries */ - $entries = \glob($this->workDir . \DIRECTORY_SEPARATOR . '*') ?: []; - foreach ($entries as $entry) { - \is_link($entry) || \is_file($entry) ? @\unlink($entry) : @\rmdir($entry); - } - @\rmdir($this->workDir); - } - - /** - * @param string $name - * @param array $args - * @return mixed - */ - private static function invoke(string $name, array $args) - { - $method = new ReflectionMethod('CertificateDownloader', $name); - $method->setAccessible(true); - - return $method->invokeArgs(null, $args); - } - - /** - * @param string $path - * @return int - */ - private static function permissionOf(string $path): int - { - \clearstatcache(true, $path); - $stat = \lstat($path); - - return false === $stat ? -1 : ((int) $stat['mode'] & 0777); - } - - public function testAtomicDumpKeepsTheDefaultPermissionForANewOne(): void - { - $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; - - self::assertTrue(self::invoke('atomicDump', [$path, 'CERT'])); - self::assertSame('CERT', \file_get_contents($path)); - - if (self::POSIX) { - $mask = \umask(); - self::assertSame(0666 & ~$mask, self::permissionOf($path), 'a brand new certificate shall be created as the `umask` says'); - } - } - - public function testAtomicDumpReplacesThePreviousOne(): void - { - $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; - \file_put_contents($path, 'OLD'); - - self::assertTrue(self::invoke('atomicDump', [$path, 'NEW'])); - self::assertSame('NEW', \file_get_contents($path)); - - if (self::POSIX) { - $mask = \umask(); - self::assertSame(0666 & ~$mask, self::permissionOf($path), 'the replacement is created as the `umask` says'); - } - } - - public function testAtomicDumpDoesNotFollowTheSymlink(): void - { - if (!self::POSIX) { - self::markTestSkipped('Creating a symlink requires the privilege on Windows.'); - } - - $victim = $this->workDir . \DIRECTORY_SEPARATOR . 'victim'; - $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; - \file_put_contents($victim, 'UNTOUCHED'); - \symlink($victim, $path); - - self::assertTrue(self::invoke('atomicDump', [$path, 'CERT'])); - - self::assertSame('UNTOUCHED', \file_get_contents($victim), 'the symlink target shall never be written'); - self::assertFalse(\is_link($path), 'the symlink itself shall be replaced by the certificate'); - self::assertSame('CERT', \file_get_contents($path)); - } - - public function testAtomicDumpFailsOnUnwritableDirectory(): void - { - if (!self::POSIX || (self::hasUid() && 0 === \posix_geteuid())) { - self::markTestSkipped('The `root` user bypasses the permission check.'); - } - - $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; - \chmod($this->workDir, 0500); - - \ob_start(); - $saved = self::invoke('atomicDump', [$path, 'CERT']); - $message = (string) \ob_get_clean(); - - self::assertFalse($saved); - self::assertStringContainsString('Failed to exclusively create the temporary file', $message); - } - - public function testAtomicDumpLeavesNoTemporaryFileBehind(): void - { - $path = $this->workDir . \DIRECTORY_SEPARATOR . 'wechatpay_ABC.pem'; - - self::assertTrue(self::invoke('atomicDump', [$path, 'CERT'])); - - /** @var string[] $leftovers */ - $leftovers = \glob($this->workDir . \DIRECTORY_SEPARATOR . '*.tmp') ?: []; - self::assertSame([], $leftovers); - } - - public function testSizeOfReportsTheAbsentPath(): void - { - $absent = $this->workDir . \DIRECTORY_SEPARATOR . 'absent.pem'; - self::assertSame(-1, self::invoke('sizeOf', [$absent])); - - $path = $this->workDir . \DIRECTORY_SEPARATOR . 'sized'; - \file_put_contents($path, '12345'); - self::assertSame(5, self::invoke('sizeOf', [$path])); - } - - /** - * Run the `createPrivateDir` within an isolated temporary directory, so that the - * real one of the current user is never touched. - * - * @param string $tmpdir - * @param callable(string):void $prepare - * @param ?string $umask - The octal `umask` to apply onto the isolated process - * @return array{string,string} - */ - private static function createPrivateDirWithin(string $tmpdir, callable $prepare, ?string $umask = null): array - { - $prepare($tmpdir); - - $script = <<<'PHP' -setAccessible(true); -$dir = $method->invoke(null); -fwrite(STDERR, null === $dir ? '' : $dir); -PHP; - $runner = $tmpdir . \DIRECTORY_SEPARATOR . 'runner.php'; - \file_put_contents($runner, $script); - - $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - $env = ['TMPDIR' => $tmpdir, 'TMP' => $tmpdir, 'TEMP' => $tmpdir, 'PATH' => (string) \getenv('PATH')]; - if (null !== $umask) { - $env['TEST_UMASK'] = $umask; - } - $command = \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg($runner) - . ' ' . \escapeshellarg(WECHATPAY_CERTIFICATE_DOWNLOADER_SOURCE); - - $process = \proc_open($command, $descriptors, $pipes, $tmpdir, $env); - if (!\is_resource($process)) { - self::fail('Cannot spawn the isolated PHP process.'); - } - $stdout = (string) \stream_get_contents($pipes[1]); - $stderr = (string) \stream_get_contents($pipes[2]); - \fclose($pipes[1]); - \fclose($pipes[2]); - \proc_close($process); - - return [$stderr, $stdout]; - } - - public function testCreatePrivateDirIsOwnedAndNotWritableByOthers(): void - { - $tmpdir = $this->workDir; - [$dir, $prompt] = self::createPrivateDirWithin($tmpdir, static function (): void {}); - - self::assertNotSame('', $dir, 'the private directory shall be created'); - self::assertDirectoryExists($dir); - self::assertStringContainsString($dir, $prompt, 'the resolved path shall be prompted out'); - - if (self::POSIX) { - $mode = self::permissionOf($dir); - self::assertSame(0, $mode & 0022, 'the directory shall never be writable by the group nor the others'); - self::assertSame(0700, $mode & 0700, 'the owner shall fully access the directory'); - } - - self::assertSame( - 1, - self::hasUid() - ? \preg_match('#^wechatpay-' . \posix_geteuid() . '$#', \basename($dir)) - : \preg_match('#^wechatpay-[0-9a-f]{16}$#', \basename($dir)), - 'the directory shall be named by the uid, or randomly while that is unavailable' - ); - } - - public function testCreatePrivateDirStaysUnwritableByOthersUnderALooseUmask(): void - { - if (!self::POSIX) { - self::markTestSkipped('The POSIX permission is not applicable on Windows.'); - } - - [$dir] = self::createPrivateDirWithin($this->workDir, static function (): void {}, '0000'); - - self::assertNotSame('', $dir); - self::assertSame( - 0, - self::permissionOf($dir) & 0022, - 'even the most permissive `umask` shall not open the directory up for the group nor the others' - ); - } - - public function testCreatePrivateDirRefusesToReuseARegularFile(): void - { - if (!self::hasUid()) { - self::markTestSkipped('The directory name is unpredictable without a reliable uid, hence nothing to preempt.'); - } - - $tmpdir = $this->workDir; - $taken = 'wechatpay-' . \posix_geteuid(); - - [$dir, $prompt] = self::createPrivateDirWithin($tmpdir, static function (string $base) use ($taken): void { - \file_put_contents($base . \DIRECTORY_SEPARATOR . $taken, 'preempted'); - }); - - self::assertSame('', $dir, 'a preempted path shall be refused'); - self::assertStringContainsString('Refused to reuse', $prompt); - } - - public function testCreatePrivateDirRefusesTheLoosePermission(): void - { - if (!self::POSIX || !self::hasUid()) { - self::markTestSkipped('The POSIX permission is not applicable on Windows.'); - } - - $tmpdir = $this->workDir; - $taken = 'wechatpay-' . \posix_geteuid(); - - [$dir, $prompt] = self::createPrivateDirWithin($tmpdir, static function (string $base) use ($taken): void { - \mkdir($base . \DIRECTORY_SEPARATOR . $taken, 0777); - \chmod($base . \DIRECTORY_SEPARATOR . $taken, 0777); - }); - - self::assertSame('', $dir, 'a group/world writable directory shall be refused'); - self::assertStringContainsString('Refused to reuse', $prompt); - } - - public function testCreatePrivateDirReusesTheCompliantOne(): void - { - if (!self::POSIX || !self::hasUid()) { - self::markTestSkipped('A randomly named directory is never reused on the platform without a reliable uid.'); - } - - $tmpdir = $this->workDir; - $taken = 'wechatpay-' . \posix_geteuid(); - - [$dir] = self::createPrivateDirWithin($tmpdir, static function (string $base) use ($taken): void { - \mkdir($base . \DIRECTORY_SEPARATOR . $taken, 0755); - \chmod($base . \DIRECTORY_SEPARATOR . $taken, 0755); - }); - - self::assertSame($tmpdir . \DIRECTORY_SEPARATOR . $taken, $dir, 'an own directory which is readable by the others shall still be reused'); - } -} From 721ae0172814a6e84a223ecb9fb8f5e5949a4ddc Mon Sep 17 00:00:00 2001 From: flyerH Date: Wed, 16 Sep 2026 19:42:17 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +- .../MerchantService/Images/DownloadTest.php | 62 ++++++++++--------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1b16b..e2055d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## [1.5.0](../../compare/v1.4.12...v1.5.0) - 2026-09-15 -- **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`,运行时会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; +- **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`;``段在无`ext-posix`的环境(如`Windows`)上取每次运行随机生成的串,此时目录逐次新建而不复用,运行时均会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; - 证书写入由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限由`umask`决定,此前手工设置的权限、属主/属组等不再保留,如有依赖请在每次下载后重设; - 优化`bin/CertificateDownloader.php`证书写入的完整性校验及失败反馈,写入失败、应答无证书或请求异常时以退出码`1`结束,不再静默返回`0`; -- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对`{+var}`保留展开的断言,固定校验展开结果与原串一致,并在存在双重编码缺陷的`guzzlehttp/uri-template`(低于`v1.0.6`)上跳过,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); +- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对请求目标的断言,改为逐一比对预期串:`{var}`简单展开恒为双重编码,`{+var}`保留展开自`guzzlehttp/uri-template@v1.0.6`起保持原串、此前为双重编码,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 diff --git a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php index cf37eae..a366b0f 100644 --- a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php +++ b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php @@ -2,10 +2,13 @@ namespace WeChatPay\Tests\OpenAPI\V3\MerchantService\Images; +use function ltrim; use function rtrim; use function file_get_contents; use function sprintf; +use function version_compare; +use Composer\InstalledVersions; use WeChatPay\Builder; use WeChatPay\Crypto\Rsa; use GuzzleHttp\Handler\MockHandler; @@ -14,7 +17,6 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\LazyOpenStream; -use GuzzleHttp\UriTemplate\UriTemplate; use Psr\Http\Message\ResponseInterface; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; @@ -36,23 +38,33 @@ private function guzzleMockStack(): HandlerStack /** @var string The media_slot_url by a community reporting */ private const MEDIA_ID = 'ChsyMDAyMDgwMjAyMjAyMTgxMTA0NDEzMTEwMzASGzMwMDIwMDAyMDIyMDIxODE1MDQ0MTcwOTI5NhgAIO%2FFR1pAGKAMwAjgB'; + /** @var string The `self::MEDIA_ID` whose pct-encoded triplet was encoded once more, aka the double pct-encoded */ + private const MEDIA_ID_DOUBLE_ENCODED = 'ChsyMDAyMDgwMjAyMjAyMTgxMTA0NDEzMTEwMzASGzMwMDIwMDAyMDIyMDIxODE1MDQ0MTcwOTI5NhgAIO%252FFR1pAGKAMwAjgB'; + + /** @var string The request target prefix of the images downloading API */ + private const TARGET_PREFIX = '/v3/merchant-service/images/'; + /** - * Skip whenever the installed `guzzlehttp/uri-template` still double-encodes the pct-encoded - * triplets on the `{+var}` reserved expansion, which was fixed since `v1.0.6`(PHP>=7.2.5). + * The `{+var}` reserved expansion preserves the pct-encoded triplets since + * `guzzlehttp/uri-template@v1.0.6`(PHP>=7.2.5), the elder ones double-encode them. + * + * Determined by the installed version rather than by expanding the template, because the + * expander is exact the one under testing, ref guzzle/uri-template#18. */ - private static function skipUnlessReservedExpansionFixed(): void + private static function reservedExpansionTarget(): string { - if (self::MEDIA_ID !== UriTemplate::expand('{+media_slot_url}', ['media_slot_url' => self::MEDIA_ID])) { - self::markTestSkipped('The installed `guzzlehttp/uri-template` double-encodes the `{+var}` expansion, ref guzzle/uri-template#18'); - } + $version = ltrim((string) InstalledVersions::getPrettyVersion('guzzlehttp/uri-template'), 'v'); + + return self::TARGET_PREFIX . (version_compare($version, '1.0.6', '>=') + ? self::MEDIA_ID : self::MEDIA_ID_DOUBLE_ENCODED); } /** * @param array $config - * @param string $assertMethod + * @param string $expectedTarget * @return array{\WeChatPay\BuilderChainable,HandlerStack} */ - private function newInstance(array $config, string $assertMethod): array + private function newInstance(array $config, string $expectedTarget): array { $instance = Builder::factory($config + ['handler' => $this->guzzleMockStack(),]); @@ -61,18 +73,16 @@ private function newInstance(array $config, string $assertMethod): array $stack = clone $stack; $stack->remove('verifier'); - $stack->push(Middleware::tap(/* before */static function (RequestInterface $request) use ($assertMethod) { + $stack->push(Middleware::tap(/* before */static function (RequestInterface $request) use ($expectedTarget) { self::assertTrue($request->hasHeader('Authorization')); self::assertStringStartsWith('WECHATPAY2-SHA256-RSA2048', $request->getHeaderLine('Authorization')); - $target = $request->getRequestTarget(); - self::{$assertMethod}('/v3/merchant-service/images/' . self::MEDIA_ID, $target); - }, /* after */static function (RequestInterface $request) use ($assertMethod) { + self::assertEquals($expectedTarget, $request->getRequestTarget()); + }, /* after */static function (RequestInterface $request) use ($expectedTarget) { self::assertTrue($request->hasHeader('Authorization')); self::assertStringStartsWith('WECHATPAY2-SHA256-RSA2048', $request->getHeaderLine('Authorization')); - $target = $request->getRequestTarget(); - self::{$assertMethod}('/v3/merchant-service/images/' . self::MEDIA_ID, $target); + self::assertEquals($expectedTarget, $request->getRequestTarget()); })); return [$instance, $stack]; @@ -106,11 +116,11 @@ public function mockDataProvider(): array */ public function testGet(array $config, string $slot, ResponseInterface $respondor): void { - // Note here: using the `UriTemplate` may be caused that, **IT IS NOT SAME TO** the original URI, - // because the `$slot` is used onto the `signature` algorithm. + // Note here: the `{var}` simple expansion pct-encodes the `%` of the `$slot`, which means the + // request target is **NOT SAME TO** the original URI, while the `$slot` is used onto the `signature` algorithm. // More @see https://github.com/guzzle/uri-template/issues/18 // And **NO IDEA** about the platform HOW TO VERIFY the `$slot` while there contains the double pct-encoded characters. - [$endpoint, $stack] = $this->newInstance($config, 'assertNotEquals'); + [$endpoint, $stack] = $this->newInstance($config, self::TARGET_PREFIX . self::MEDIA_ID_DOUBLE_ENCODED); $this->mock->reset(); $this->mock->append($respondor); @@ -139,9 +149,7 @@ public function testGet(array $config, string $slot, ResponseInterface $respondo */ public function testGetWithReservedExpansion(array $config, string $slot, ResponseInterface $respondor): void { - self::skipUnlessReservedExpansionFixed(); - - [$endpoint, $stack] = $this->newInstance($config, 'assertEquals'); + [$endpoint, $stack] = $this->newInstance($config, self::reservedExpansionTarget()); $this->mock->reset(); $this->mock->append($respondor); @@ -170,11 +178,11 @@ private static function responseAssertion(ResponseInterface $response): void */ public function testGetAsync(array $config, string $slot, ResponseInterface $respondor): void { - // Note here: using the `UriTemplate` may be caused that, **IT IS NOT SAME TO** the original URI, - // because the `$slot` is used onto the `signature` algorithm. + // Note here: the `{var}` simple expansion pct-encodes the `%` of the `$slot`, which means the + // request target is **NOT SAME TO** the original URI, while the `$slot` is used onto the `signature` algorithm. // More @see https://github.com/guzzle/uri-template/issues/18 // And **NO IDEA** about the platform HOW TO VERIFY the `$slot` while there contains the double pct-encoded characters. - [$endpoint, $stack] = $this->newInstance($config, 'assertNotEquals'); + [$endpoint, $stack] = $this->newInstance($config, self::TARGET_PREFIX . self::MEDIA_ID_DOUBLE_ENCODED); $this->mock->reset(); $this->mock->append($respondor); @@ -202,9 +210,7 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res */ public function testGetAsyncWithReservedExpansion(array $config, string $slot, ResponseInterface $respondor): void { - self::skipUnlessReservedExpansionFixed(); - - [$endpoint, $stack] = $this->newInstance($config, 'assertEquals'); + [$endpoint, $stack] = $this->newInstance($config, self::reservedExpansionTarget()); $this->mock->reset(); $this->mock->append($respondor); @@ -225,7 +231,7 @@ public function testGetAsyncWithReservedExpansion(array $config, string $slot, R */ public function testUseStandardGuzzleHttpClient(array $config, string $slot, ResponseInterface $respondor): void { - [$endpoint, $stack] = $this->newInstance($config, 'assertEquals'); + [$endpoint, $stack] = $this->newInstance($config, self::TARGET_PREFIX . self::MEDIA_ID); $relativeUrl = 'v3/merchant-service/images/' . $slot; $fullUri = 'https://api.mch.weixin.qq.com/' . $relativeUrl; From 8e574d27ae8f0846ebc5d708e528821d5557f47a Mon Sep 17 00:00:00 2001 From: flyerH Date: Wed, 16 Sep 2026 19:59:04 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2055d1..d378683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`;``段在无`ext-posix`的环境(如`Windows`)上取每次运行随机生成的串,此时目录逐次新建而不复用,运行时均会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; - 证书写入由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限由`umask`决定,此前手工设置的权限、属主/属组等不再保留,如有依赖请在每次下载后重设; - 优化`bin/CertificateDownloader.php`证书写入的完整性校验及失败反馈,写入失败、应答无证书或请求异常时以退出码`1`结束,不再静默返回`0`; -- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对请求目标的断言,改为逐一比对预期串:`{var}`简单展开恒为双重编码,`{+var}`保留展开自`guzzlehttp/uri-template@v1.0.6`起保持原串、此前为双重编码,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); +- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对请求目标的断言,改为逐一比对预期串:`{var}`在各版本下恒为双重编码;`{+var}`的结果视`guzzlehttp/uri-template`版本而定,`v1.0.6`及以上保持原串,更早版本为双重编码,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 From 08eaca2c44ac4dcfa5546ad6e1776456c0670d07 Mon Sep 17 00:00:00 2001 From: flyerH Date: Thu, 17 Sep 2026 11:48:33 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E7=BB=86=E8=8A=82=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++-- bin/CertificateDownloader.php | 26 ++++++++++++++++---------- bin/README.md | 2 +- composer.json | 1 + 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d378683..e046c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ - **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`;``段在无`ext-posix`的环境(如`Windows`)上取每次运行随机生成的串,此时目录逐次新建而不复用,运行时均会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; - 证书写入由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限由`umask`决定,此前手工设置的权限、属主/属组等不再保留,如有依赖请在每次下载后重设; -- 优化`bin/CertificateDownloader.php`证书写入的完整性校验及失败反馈,写入失败、应答无证书或请求异常时以退出码`1`结束,不再静默返回`0`; -- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对请求目标的断言,改为逐一比对预期串:`{var}`在各版本下恒为双重编码;`{+var}`的结果视`guzzlehttp/uri-template`版本而定,`v1.0.6`及以上保持原串,更早版本为双重编码,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); +- 优化`bin/CertificateDownloader.php`证书写入的完整性校验及失败反馈,写入失败、应答无证书、请求异常或必填参数缺失时以退出码`1`结束,不再静默返回`0`;`-h`/`--help`与`-V`/`--version`仍返回`0`; +- 修正[DownloadTest.php](./tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php)对请求目标的断言,改为逐一比对预期串:`{+var}`的结果视`guzzlehttp/uri-template`版本而定,`v1.0.6`及以上保持原串,更早版本为双重编码,相关修复见[guzzle/uri-template#18](https://github.com/guzzle/uri-template/issues/18); ## [1.4.12](../../compare/v1.4.11...v1.4.12) - 2025-01-27 diff --git a/bin/CertificateDownloader.php b/bin/CertificateDownloader.php index 611eafb..234a0c2 100755 --- a/bin/CertificateDownloader.php +++ b/bin/CertificateDownloader.php @@ -35,7 +35,7 @@ public function run(): void { $opts = $this->parseOpts(); - if (!$opts || isset($opts['help'])) { + if (isset($opts['help'])) { $this->printHelp(); return; } @@ -43,6 +43,10 @@ public function run(): void self::prompt(ClientDecoratorInterface::VERSION); return; } + if (!$opts) { + $this->printHelp(); + exit(1); + } if (!$this->job($opts)) { exit(1); } @@ -181,7 +185,7 @@ private static function certsRecorder(string $outputDir, array &$certs, bool &$f $serialNo = (string) $row->serial_no; $content = (string) ($certs[$serialNo] ?? ''); - if (!\preg_match('#^[0-9A-Fa-f]{1,64}$#', $serialNo) || '' === $content) { + if (!\preg_match('#^[0-9A-Fa-f]{1,64}$#D', $serialNo) || '' === $content) { $failed = true; self::prompt(\sprintf('Skipped the certificate #%s: unexpected serial number or empty content.', $index)); @@ -233,7 +237,7 @@ private static function atomicDump(string $path, string $content): bool if (\function_exists('fsync')) { @\fsync($handle); } - $closed = \fclose($handle); + $closed = @\fclose($handle); if (!$flushed || !$closed || $written !== $length || $length !== self::sizeOf($temp)) { @\unlink($temp); @@ -309,6 +313,14 @@ private function parseOpts(): ?array return null; } + // Both of the `help` and `version` are prior to the mandatory option(s) checking below. + if (isset($parsed['h']) || isset($parsed['help'])) { + return ['help' => true]; + } + if (isset($parsed['V']) || isset($parsed['version'])) { + return ['version' => true]; + } + $args = []; foreach ($opts as $opt) { [$key, $alias, $mandatory] = $opt; @@ -321,12 +333,6 @@ private function parseOpts(): ?array } } - if (isset($parsed['h']) || isset($parsed['help'])) { - $args['help'] = true; - } - if (isset($parsed['V']) || isset($parsed['version'])) { - $args['version'] = true; - } return $args; } @@ -343,7 +349,7 @@ private function printHelp(): void ' 商户的私钥文件', ' -k, --key= APIv3密钥', ' -o, --output=[outputFilePath]', - ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹下新建的当前用户专属目录', + ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹下新建的专属目录,运行时打印其完整路径', ' -u, --baseuri=[baseUri] 接入点,可选,默认为 ' . self::DEFAULT_BASE_URI, ' -V, --version Print version information and exit.', ' -h, --help Show this help message and exit.', '' diff --git a/bin/README.md b/bin/README.md index 8b8167d..a6b09e4 100644 --- a/bin/README.md +++ b/bin/README.md @@ -18,7 +18,7 @@ Options: 商户的私钥文件 -k, --key= ApiV3Key -o, --output=[outputFilePath] - 下载成功后保存证书的路径,可选参数,默认为临时文件目录夹下新建的当前用户专属目录 + 下载成功后保存证书的路径,可选,默认为临时文件目录夹下新建的专属目录,运行时打印其完整路径 -u, --baseuri=[baseUri] 接入点,默认为 https://api.mch.weixin.qq.com/ -V, --version Print version information and exit. -h, --help Show this help message and exit. diff --git a/composer.json b/composer.json index 0f70d61..9fdc13d 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "guzzlehttp/guzzle": "^6.5 || ^7.0" }, "require-dev": { + "composer-runtime-api": "^2.0", "phpunit/phpunit": "^7.5 || ^8.5.16 || ^9.3.5", "phpstan/phpstan": "^0.12.89 || ^1.0" }, From 79578c4e539b3476453d93a460ae0fbfb8665e02 Mon Sep 17 00:00:00 2001 From: flyerH Date: Thu, 17 Sep 2026 16:39:16 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E7=9C=9F=E5=AE=9E=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/CertificateDownloader.php | 48 +++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/bin/CertificateDownloader.php b/bin/CertificateDownloader.php index 234a0c2..5796f03 100755 --- a/bin/CertificateDownloader.php +++ b/bin/CertificateDownloader.php @@ -131,15 +131,25 @@ private static function createPrivateDir(): ?string $uid = \function_exists('posix_geteuid') ? \posix_geteuid() : null; $dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-' . ($uid ?? \bin2hex(\random_bytes(8))); + \error_clear_last(); if (!@\mkdir($dir, 0755)) { if (null === $uid) { - self::prompt(\sprintf('Failed to create the private directory `%s`, please assign the `-o` option.', $dir)); + self::prompt(\sprintf( + 'Failed to create the private directory `%s`: %s, please assign the `-o` option.', + $dir, + self::lastErrorMessage() + )); return null; } + \error_clear_last(); $stat = @\lstat($dir); if (false === $stat) { - self::prompt(\sprintf('Failed to create the private directory `%s`, please assign the `-o` option.', $dir)); + self::prompt(\sprintf( + 'Failed to stat the existing `%s`: %s, please assign the `-o` option.', + $dir, + self::lastErrorMessage() + )); return null; } @@ -225,29 +235,45 @@ private static function atomicDump(string $path, string $content): bool { $temp = $path . '.' . \bin2hex(\random_bytes(8)) . '.tmp'; + \error_clear_last(); $handle = @\fopen($temp, 'xb'); if (false === $handle) { - self::prompt(\sprintf('Failed to exclusively create the temporary file `%s`.', $temp)); + self::prompt(\sprintf( + 'Failed to exclusively create the temporary file `%s`: %s.', + $temp, + self::lastErrorMessage() + )); return false; } $length = \strlen($content); + \error_clear_last(); $written = @\fwrite($handle, $content); $flushed = @\fflush($handle); if (\function_exists('fsync')) { @\fsync($handle); } $closed = @\fclose($handle); + // Grab it before the `sizeOf` and `unlink` below, either of them may overwrite the last error. + $reason = self::lastErrorMessage(); if (!$flushed || !$closed || $written !== $length || $length !== self::sizeOf($temp)) { @\unlink($temp); - self::prompt(\sprintf('Failed to write the whole content onto `%s`.', $temp)); + self::prompt(\sprintf( + 'Failed to write the whole content onto `%s`: %d of %d bytes were written, %s.', + $temp, + (int) $written, + $length, + $reason + )); return false; } + \error_clear_last(); if (!@\rename($temp, $path)) { + $reason = self::lastErrorMessage(); @\unlink($temp); - self::prompt(\sprintf('Failed to place the certificate onto `%s`.', $path)); + self::prompt(\sprintf('Failed to place the certificate onto `%s`: %s.', $path, $reason)); return false; } @@ -269,6 +295,18 @@ private static function sizeOf(string $path): int return false === $stat ? -1 : (int) $stat['size']; } + /** + * The message of the last error, which was suppressed by the `@` operator. + * + * Pair it with a preceding `error_clear_last()` call, otherwise a stale message may be returned. + * + * @return string - The message, or `unknown error` while there's none + */ + private static function lastErrorMessage(): string + { + return \error_get_last()['message'] ?? 'unknown error'; + } + /** * @param string $thing */ From b865be5e7304cbdbf8a84abe87f302de6dfed71d Mon Sep 17 00:00:00 2001 From: flyerH Date: Thu, 17 Sep 2026 20:11:46 +0800 Subject: [PATCH 10/10] =?UTF-8?q?cr=E9=97=AE=E9=A2=98=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/CertificateDownloader.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/CertificateDownloader.php b/bin/CertificateDownloader.php index 5796f03..54940db 100755 --- a/bin/CertificateDownloader.php +++ b/bin/CertificateDownloader.php @@ -23,6 +23,7 @@ use WeChatPay\Builder; use WeChatPay\ClientDecoratorInterface; use WeChatPay\Crypto\AesGcm; +use WeChatPay\Formatter; /** * CertificateDownloader class @@ -129,7 +130,7 @@ private function job(array $opts): bool private static function createPrivateDir(): ?string { $uid = \function_exists('posix_geteuid') ? \posix_geteuid() : null; - $dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-' . ($uid ?? \bin2hex(\random_bytes(8))); + $dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'wechatpay-' . ($uid ?? Formatter::nonce(16)); \error_clear_last(); if (!@\mkdir($dir, 0755)) { @@ -184,7 +185,7 @@ private static function certsRecorder(string $outputDir, array &$certs, bool &$f $json = \json_decode($body); $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : []; - if (!$data) { + if (0 === \count($data)) { $failed = true; self::prompt('There\'s no certificate onto the response, nothing was saved.'); @@ -233,7 +234,7 @@ private static function certsRecorder(string $outputDir, array &$certs, bool &$f */ private static function atomicDump(string $path, string $content): bool { - $temp = $path . '.' . \bin2hex(\random_bytes(8)) . '.tmp'; + $temp = $path . '.' . Formatter::nonce(16) . '.tmp'; \error_clear_last(); $handle = @\fopen($temp, 'xb');