diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9563f..e046c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 变更历史 +## [1.5.0](../../compare/v1.4.12...v1.5.0) - 2026-09-15 + +- **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-/wechatpay_<序列号>.pem`;``段在无`ext-posix`的环境(如`Windows`)上取每次运行随机生成的串,此时目录逐次新建而不复用,运行时均会打印出完整路径,依赖旧路径的脚本请相应调整或显式传入`-o`; +- 证书写入由`file_put_contents`改为「独占创建临时文件 + `rename`原子替换」,不再跟随同路径上的符号链接,写入中断时也不会损坏已有证书;证书权限由`umask`决定,此前手工设置的权限、属主/属组等不再保留,如有依赖请在每次下载后重设; +- 优化`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 - 修正`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..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 @@ -35,7 +36,7 @@ public function run(): void { $opts = $this->parseOpts(); - if (!$opts || isset($opts['help'])) { + if (isset($opts['help'])) { $this->printHelp(); return; } @@ -43,7 +44,13 @@ public function run(): void self::prompt(ClientDecoratorInterface::VERSION); return; } - $this->job($opts); + if (!$opts) { + $this->printHelp(); + exit(1); + } + if (!$this->job($opts)) { + exit(1); + } } /** @@ -71,13 +78,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 +98,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 +118,56 @@ private function job(array $opts): void } self::prompt($exception->getTraceAsString()); })->wait(); + + return !$failed; + } + + /** + * 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 ?? Formatter::nonce(16)); + + \error_clear_last(); + if (!@\mkdir($dir, 0755)) { + if (null === $uid) { + 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 stat the existing `%s`: %s, please assign the `-o` option.', + $dir, + self::lastErrorMessage() + )); + return null; + } + + $mode = (int) $stat['mode']; + + 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 and not writable by the others, 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 +175,139 @@ 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 (0 === \count($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}$#D', $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 . '.' . Formatter::nonce(16) . '.tmp'; + + \error_clear_last(); + $handle = @\fopen($temp, 'xb'); + if (false === $handle) { + 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`: %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`: %s.', $path, $reason)); + return false; + } + + 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']; + } + + /** + * 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 */ @@ -187,6 +352,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; @@ -199,12 +372,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; } @@ -221,7 +388,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 ff40403..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 cb281cc..9fdc13d 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": [ @@ -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" }, 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 diff --git a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php index eff0402..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; @@ -35,12 +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/'; + + /** + * 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 reservedExpansionTarget(): string + { + $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(),]); @@ -49,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]; @@ -94,16 +116,15 @@ 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); $this->mock->append($respondor); - $this->mock->append($respondor); $response = $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->get([ 'media_slot_url' => $slot, @@ -115,6 +136,23 @@ 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 + { + [$endpoint, $stack] = $this->newInstance($config, self::reservedExpansionTarget()); + + $this->mock->reset(); + $this->mock->append($respondor); $response = $endpoint->chain('v3/merchant-service/images/{+media_slot_url}')->get([ 'handler' => $stack, @@ -140,16 +178,15 @@ 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); $this->mock->append($respondor); - $this->mock->append($respondor); $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->getAsync([ 'media_slot_url' => $slot, @@ -163,6 +200,20 @@ 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 + { + [$endpoint, $stack] = $this->newInstance($config, self::reservedExpansionTarget()); + + $this->mock->reset(); + $this->mock->append($respondor); $endpoint->chain('v3/merchant-service/images/{+media_slot_url}')->getAsync([ 'handler' => $stack, @@ -180,7 +231,7 @@ 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'); + [$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;