Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# 变更历史

## [1.5.0](../../compare/v1.4.12...v1.5.0) - 2026-09-15

- **不兼容变更**:`bin/CertificateDownloader.php` 省略`-o`时的默认保存路径,由`<临时目录>/wechatpay_<序列号>.pem`改为其下他人不可写的专属目录,即`<临时目录>/wechatpay-<uid>/wechatpay_<序列号>.pem`;`<uid>`段在无`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)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

## 项目状态

当前版本为 `1.4.12` 版。
当前版本为 `1.5.0` 版。
项目版本遵循 [语义化版本号](https://semver.org/lang/zh-CN/)。
如果你使用的版本 `<=v1.3.2`,升级前请参考 [升级指南](UPGRADING.md)。

Expand Down
211 changes: 189 additions & 22 deletions bin/CertificateDownloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use WeChatPay\Builder;
use WeChatPay\ClientDecoratorInterface;
use WeChatPay\Crypto\AesGcm;
use WeChatPay\Formatter;

/**
* CertificateDownloader class
Expand All @@ -35,15 +36,21 @@ public function run(): void
{
$opts = $this->parseOpts();

if (!$opts || isset($opts['help'])) {
if (isset($opts['help'])) {
$this->printHelp();
return;
}
if (isset($opts['version'])) {
self::prompt(ClientDecoratorInterface::VERSION);
return;
}
$this->job($opts);
if (!$opts) {
$this->printHelp();
exit(1);
}
if (!$this->job($opts)) {
exit(1);
}
}

/**
Expand Down Expand Up @@ -71,13 +78,16 @@ private static function certsInjector(string $apiv3Key, array &$certs): callable
/**
* @param array<string,string|true> $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([
Expand All @@ -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 */
Expand All @@ -105,44 +118,196 @@ 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;
}

/**
* After `verifier` executed, wrote the platform certificate(s) onto disk.
*
* @param string $outputDir
* @param array<string,?string> $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)) {
Comment thread
flyerH marked this conversation as resolved.
$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
*/
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -221,7 +388,7 @@ private function printHelp(): void
' 商户的私钥文件',
' -k, --key=<apiv3Key> 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.', ''
Expand Down
2 changes: 1 addition & 1 deletion bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Options:
商户的私钥文件
-k, --key=<apiV3key> 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.
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion src/ClientDecoratorInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading