From 79907201ffe2bc45555c7b4caf38860f450fdb07 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 20 Apr 2025 09:27:05 +0200 Subject: [PATCH 01/11] Add ZStandard compression --- src/main/php/io/streams/Compression.class.php | 4 +- .../io/streams/compress/ZStandard.class.php | 34 ++++++++ .../compress/ZStandardInputStream.class.php | 82 +++++++++++++++++++ .../compress/ZStandardOutputStream.class.php | 65 +++++++++++++++ .../unittest/CompressionTest.class.php | 11 ++- .../ZStandardInputStreamTest.class.php | 59 +++++++++++++ .../ZStandardOutputStreamTest.class.php | 34 ++++++++ 7 files changed, 283 insertions(+), 6 deletions(-) create mode 100755 src/main/php/io/streams/compress/ZStandard.class.php create mode 100755 src/main/php/io/streams/compress/ZStandardInputStream.class.php create mode 100755 src/main/php/io/streams/compress/ZStandardOutputStream.class.php create mode 100755 src/test/php/io/streams/compress/unittest/ZStandardInputStreamTest.class.php create mode 100755 src/test/php/io/streams/compress/unittest/ZStandardOutputStreamTest.class.php diff --git a/src/main/php/io/streams/Compression.class.php b/src/main/php/io/streams/Compression.class.php index b1534d8..712dd10 100755 --- a/src/main/php/io/streams/Compression.class.php +++ b/src/main/php/io/streams/Compression.class.php @@ -1,6 +1,6 @@ add(new Gzip(), new Bzip2(), new Brotli()); + self::$algorithms= (new Algorithms())->add(new Gzip(), new Bzip2(), new Brotli(), new ZStandard()); } /** diff --git a/src/main/php/io/streams/compress/ZStandard.class.php b/src/main/php/io/streams/compress/ZStandard.class.php new file mode 100755 index 0000000..e57c6f6 --- /dev/null +++ b/src/main/php/io/streams/compress/ZStandard.class.php @@ -0,0 +1,34 @@ + 1, Compression::DEFAULT => 3, Compression::STRONGEST => 22]; + return $levels[$select] ?? $select; + } + + /** Opens an input stream for reading */ + public function open(InputStream $in): InputStream { + return new ZStandardInputStream($in); + } + + /** Opens an output stream for writing */ + public function create(OutputStream $out, int $level= Compression::DEFAULT): OutputStream { + return new ZStandardOutputStream($out, $this->level($level)); + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/ZStandardInputStream.class.php b/src/main/php/io/streams/compress/ZStandardInputStream.class.php new file mode 100755 index 0000000..e129737 --- /dev/null +++ b/src/main/php/io/streams/compress/ZStandardInputStream.class.php @@ -0,0 +1,82 @@ +in= $in; + } + + /** @see https://github.com/kjdev/php-ext-zstd/issues/64 */ + private function buffer() { + if (null === $this->buffer) { + $compressed= ''; + while ($this->in->available()) { + $compressed.= $this->in->read(); + } + + $this->buffer= zstd_uncompress($compressed); + if (false === $this->buffer) { + $e= new IOException('Failed to uncompress'); + \xp::gc(__FILE__); + throw $e; + } + } + return $this->buffer; + } + + /** + * Read a string + * + * @param int limit default 8192 + * @return string + */ + public function read($limit= 8192) { + $chunk= substr($this->buffer(), $this->position, $limit); + $this->position+= strlen($chunk); + return $chunk; + } + + /** + * Returns the number of bytes that can be read from this stream + * without blocking. + * + * @return int + */ + public function available() { + return strlen($this->buffer()) - $this->position; + } + + /** + * Close this buffer. + * + * @return void + */ + public function close() { + $this->buffer= null; + $this->in->close(); + } + + /** + * Destructor. Ensures output stream is closed. + */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/ZStandardOutputStream.class.php b/src/main/php/io/streams/compress/ZStandardOutputStream.class.php new file mode 100755 index 0000000..a4f480d --- /dev/null +++ b/src/main/php/io/streams/compress/ZStandardOutputStream.class.php @@ -0,0 +1,65 @@ + ZSTD_COMPRESS_LEVEL_MAX) { + throw new IllegalArgumentException('Level must be between '.ZSTD_COMPRESS_LEVEL_MIN.' and '.ZSTD_COMPRESS_LEVEL_MAX); + } + + $this->out= $out; + $this->level= $level; + } + + /** + * Write a string + * + * @param var $arg + * @return void + */ + public function write($arg) { + $this->buffer.= $arg; + } + + /** + * Flush this buffer + * + * @return void + */ + public function flush() { + // NOOP + } + + /** + * Closes this object. May be called more than once, which may + * not fail - that is, if the object is already closed, this + * method should have no effect. + * + * @return void + */ + public function close() { + if (null !== $this->buffer) { + $this->out->write(zstd_compress($this->buffer, $this->level)); + $this->buffer= null; + } + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/CompressionTest.class.php b/src/test/php/io/streams/compress/unittest/CompressionTest.class.php index 6db0898..1ec0c9b 100755 --- a/src/test/php/io/streams/compress/unittest/CompressionTest.class.php +++ b/src/test/php/io/streams/compress/unittest/CompressionTest.class.php @@ -14,14 +14,17 @@ private function names() { yield ['gzip', 'gzip']; yield ['bzip2', 'bzip2']; yield ['brotli', 'brotli']; + yield ['zstandard', 'zstandard']; // File extensions yield ['.gz', 'gzip']; yield ['.bz2', 'bzip2']; yield ['.br', 'brotli']; + yield ['.zstd', 'zstandard']; // HTTP Content-Encoding aliases yield ['br', 'brotli']; + yield ['zstd', 'zstandard']; } /** @return iterable */ @@ -38,7 +41,7 @@ public function enumerating_included_algorithms() { foreach (Compression::algorithms() as $name => $algorithm) { $names[]= $name; } - Assert::equals(['gzip', 'bzip2', 'brotli'], $names); + Assert::equals(['gzip', 'bzip2', 'brotli', 'zstandard'], $names); } #[Test] @@ -61,17 +64,17 @@ public function algorithms_named($name, $expected) { Assert::equals($expected, Compression::algorithms()->named($name)->name()); } - #[Test, Values([['gzip', 'zlib'], ['bzip2', 'bzip2'], ['brotli', 'brotli']])] + #[Test, Values([['gzip', 'zlib'], ['bzip2', 'bzip2'], ['brotli', 'brotli'], ['zstandard', 'zstd']])] public function supported($compression, $extension) { Assert::equals(extension_loaded($extension), Compression::algorithms()->named($compression)->supported()); } - #[Test, Values([['gzip', 'gzip'], ['bzip2', 'bzip2'], ['brotli', 'br']])] + #[Test, Values([['gzip', 'gzip'], ['bzip2', 'bzip2'], ['brotli', 'br'], ['zstandard', 'zstd']])] public function token($compression, $expected) { Assert::equals($expected, Compression::algorithms()->named($compression)->token()); } - #[Test, Values([['gzip', '.gz'], ['bzip2', '.bz2'], ['brotli', '.br']])] + #[Test, Values([['gzip', '.gz'], ['bzip2', '.bz2'], ['brotli', '.br'], ['zstandard', '.zstd']])] public function extension($compression, $expected) { Assert::equals($expected, Compression::algorithms()->named($compression)->extension()); } diff --git a/src/test/php/io/streams/compress/unittest/ZStandardInputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/ZStandardInputStreamTest.class.php new file mode 100755 index 0000000..bd8870c --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/ZStandardInputStreamTest.class.php @@ -0,0 +1,59 @@ +read(); + }); + $in->close(); + } + + #[Test, Values(from: 'compressable')] + public function read_compressed($level, $bytes) { + $in= new ZStandardInputStream(new MemoryInputStream(zstd_compress($bytes, $level))); + $read= $in->read(); + $rest= $in->available(); + $in->close(); + + Assert::equals($bytes, $read); + Assert::equals(0, $rest); + } + + #[Test, Values([1, 8192, 16384])] + public function read_all($length) { + $bytes= random_bytes($length); + $in= new ZStandardInputStream(new MemoryInputStream(zstd_compress($bytes))); + + $read= ''; + while ($in->available()) { + $read.= $in->read(); + } + $in->close(); + + Assert::equals($bytes, $read); + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/ZStandardOutputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/ZStandardOutputStreamTest.class.php new file mode 100755 index 0000000..523878a --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/ZStandardOutputStreamTest.class.php @@ -0,0 +1,34 @@ +write('Hello'); + $fixture->write(' '); + $fixture->write('World'); + $fixture->close(); + + Assert::equals('Hello World', zstd_uncompress($out->bytes())); + } +} \ No newline at end of file From 8d26d9afbe82bd0a3589a1e7106e2ae94e865e08 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 29 Jul 2025 20:40:14 +0200 Subject: [PATCH 02/11] Use incremental (un)compress APIs added in kjdev/php-ext-zstd#79 --- .../compress/ZStandardInputStream.class.php | 40 +++++++------------ .../compress/ZStandardOutputStream.class.php | 13 +++--- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/main/php/io/streams/compress/ZStandardInputStream.class.php b/src/main/php/io/streams/compress/ZStandardInputStream.class.php index e129737..abe2429 100755 --- a/src/main/php/io/streams/compress/ZStandardInputStream.class.php +++ b/src/main/php/io/streams/compress/ZStandardInputStream.class.php @@ -11,8 +11,7 @@ * @see https://github.com/kjdev/php-ext-zstd */ class ZStandardInputStream implements InputStream { - private $in; - private $buffer= null, $position= 0; + private $in, $handle; /** * Creates a new compressing output stream @@ -21,24 +20,7 @@ class ZStandardInputStream implements InputStream { */ public function __construct(InputStream $in) { $this->in= $in; - } - - /** @see https://github.com/kjdev/php-ext-zstd/issues/64 */ - private function buffer() { - if (null === $this->buffer) { - $compressed= ''; - while ($this->in->available()) { - $compressed.= $this->in->read(); - } - - $this->buffer= zstd_uncompress($compressed); - if (false === $this->buffer) { - $e= new IOException('Failed to uncompress'); - \xp::gc(__FILE__); - throw $e; - } - } - return $this->buffer; + $this->handle= zstd_uncompress_init(); } /** @@ -48,9 +30,13 @@ private function buffer() { * @return string */ public function read($limit= 8192) { - $chunk= substr($this->buffer(), $this->position, $limit); - $this->position+= strlen($chunk); - return $chunk; + $bytes= zstd_uncompress_add($this->handle, $this->in->read($limit)); + if (false === $bytes) { + $e= new IOException('Failed to uncompress'); + \xp::gc(__FILE__); + throw $e; + } + return $bytes; } /** @@ -60,7 +46,7 @@ public function read($limit= 8192) { * @return int */ public function available() { - return strlen($this->buffer()) - $this->position; + return $this->in->available(); } /** @@ -69,8 +55,10 @@ public function available() { * @return void */ public function close() { - $this->buffer= null; - $this->in->close(); + if ($this->handle) { + $this->handle= null; + $this->in->close(); + } } /** diff --git a/src/main/php/io/streams/compress/ZStandardOutputStream.class.php b/src/main/php/io/streams/compress/ZStandardOutputStream.class.php index a4f480d..2321ef1 100755 --- a/src/main/php/io/streams/compress/ZStandardOutputStream.class.php +++ b/src/main/php/io/streams/compress/ZStandardOutputStream.class.php @@ -11,8 +11,7 @@ * @see https://github.com/kjdev/php-ext-zstd */ class ZStandardOutputStream implements OutputStream { - private $out, $level; - private $buffer= ''; + private $out, $handle; /** * Creates a new compressing output stream @@ -27,7 +26,7 @@ public function __construct(OutputStream $out, $level= ZSTD_COMPRESS_LEVEL_DEFAU } $this->out= $out; - $this->level= $level; + $this->handle= zstd_compress_init($level); } /** @@ -37,7 +36,7 @@ public function __construct(OutputStream $out, $level= ZSTD_COMPRESS_LEVEL_DEFAU * @return void */ public function write($arg) { - $this->buffer.= $arg; + $this->out->write(zstd_compress_add($this->handle, $arg, false)); } /** @@ -57,9 +56,9 @@ public function flush() { * @return void */ public function close() { - if (null !== $this->buffer) { - $this->out->write(zstd_compress($this->buffer, $this->level)); - $this->buffer= null; + if (null !== $this->handle) { + $this->out->write(zstd_compress_add($this->handle, '', true)); + $this->handle= null; } } } \ No newline at end of file From 399d1e4799802a90e25d6a1d146b2b940e67bdcb Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 29 Jul 2025 20:42:18 +0200 Subject: [PATCH 03/11] Adjust README [skip ci] --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 200aac3..5b41eea 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Compression streams [![Supports PHP 8.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-8_0plus.svg)](http://php.net/) [![Latest Stable Version](https://poser.pugx.org/xp-forge/compression/version.svg)](https://packagist.org/packages/xp-forge/compression) -Compressing output and decompressing input streams including GZip, BZip2 and Brotli. +Compressing output and decompressing input streams including GZip, BZip2, ZStandard and Brotli. Examples -------- @@ -44,6 +44,7 @@ Compression algorithms are implemented in C and thus require a specific PHP exte * **GZip** - requires PHP's ["zlib" extension](https://www.php.net/zlib) * **Bzip2** - requires PHP's ["bzip2" extension](https://www.php.net/bzip2) * **Brotli** - requires https://github.com/kjdev/php-ext-brotli +* **ZStandard** - requires https://github.com/kjdev/php-ext-zstd Accessing these algorithms can be done via the `Compression` API: From 9961616af1b276f5f403efc6eeee89789aa42097 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 29 Jul 2025 20:47:54 +0200 Subject: [PATCH 04/11] Include ZStandard in algorithms --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5b41eea..741e22d 100755 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ io.streams.compress.Algorithms@{ io.streams.compress.Gzip(token: gzip, extension: .gz, supported: true, levels: 1..9) io.streams.compress.Bzip2(token: bzip2, extension: .bz2, supported: false, levels: 1..9) io.streams.compress.Brotli(token: br, extension: .br, supported: true, levels: 1..11) + io.streams.compress.ZStandard(token: zstd, extension: .zstd, supported: true, levels: 1..22) } ``` From 5e3ccb5e1aeba8b58ed10207ee1c6fde28a24392 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 31 Jul 2025 21:41:06 +0200 Subject: [PATCH 05/11] Implement compress() and decompress() --- src/main/php/io/streams/compress/ZStandard.class.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/php/io/streams/compress/ZStandard.class.php b/src/main/php/io/streams/compress/ZStandard.class.php index e57c6f6..5b747cf 100755 --- a/src/main/php/io/streams/compress/ZStandard.class.php +++ b/src/main/php/io/streams/compress/ZStandard.class.php @@ -22,6 +22,16 @@ public function level(int $select): int { return $levels[$select] ?? $select; } + /** Compresses data */ + public function compress(string $data, int $level= Compression::DEFAULT): string { + return zstd_compress($data, $this->level($level)); + } + + /** Decompresses bytes */ + public function decompress(string $bytes): string { + return zstd_uncompress($bytes); + } + /** Opens an input stream for reading */ public function open(InputStream $in): InputStream { return new ZStandardInputStream($in); From 0b7bdf4c6a60c346605ad96ee6f565c41dec8756 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 31 Jul 2025 21:43:25 +0200 Subject: [PATCH 06/11] Add test for erroneous ZStandard data --- src/main/php/io/streams/compress/ZStandard.class.php | 8 +++++++- .../streams/compress/unittest/CompressionTest.class.php | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/php/io/streams/compress/ZStandard.class.php b/src/main/php/io/streams/compress/ZStandard.class.php index 5b747cf..06cfe46 100755 --- a/src/main/php/io/streams/compress/ZStandard.class.php +++ b/src/main/php/io/streams/compress/ZStandard.class.php @@ -1,5 +1,6 @@ supported() && PHP_VERSION_ID >= 70400) { yield [$bzip2, "BZh61AY&SY\331"]; } + + $zstd= $algorithms->named('zstd'); + if ($zstd->supported()) { + yield [$zstd, ""]; + } } #[Test] From 4769e6bf997284dcd797c23fe53580740ff37245 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 31 Jul 2025 21:45:11 +0200 Subject: [PATCH 07/11] Add test for erroneous Brotli data --- src/main/php/io/streams/compress/Brotli.class.php | 8 +++++++- .../compress/unittest/CompressionTest.class.php | 11 ++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/php/io/streams/compress/Brotli.class.php b/src/main/php/io/streams/compress/Brotli.class.php index 67977e5..b478834 100755 --- a/src/main/php/io/streams/compress/Brotli.class.php +++ b/src/main/php/io/streams/compress/Brotli.class.php @@ -1,5 +1,6 @@ "]; } - $zstd= $algorithms->named('zstd'); - if ($zstd->supported()) { - yield [$zstd, ""]; + $brotli= $algorithms->named('brotli'); + if ($brotli->supported()) { + yield [$brotli, ""]; + } + + $zstandard= $algorithms->named('zstandard'); + if ($zstandard->supported()) { + yield [$zstandard, ""]; } } From 69f13cc6cdb1a868e610b8c411356e277d8c5a72 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 31 Jul 2025 21:49:08 +0200 Subject: [PATCH 08/11] Add ZStandard extension --- .github/workflows/ci.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fd3806..ef2cde8 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,14 +46,35 @@ jobs: sudo cp brotli.ini /etc/php/${{ matrix.php-versions }}/mods-available/ && sudo phpenmod -v ${{ matrix.php-versions }} brotli + - name: Add ZStandard extension (Ubuntu) + if: runner.os != 'Windows' && matrix.php-versions == '8.4' + run: > + git clone --recursive --depth=1 https://github.com/kjdev/php-ext-zstd.git && + cd php-ext-zstd && + phpize && + ./configure --enable-zstd && + make -j $(nproc) && + echo "extension=zstd.so" > zstd.ini && + sudo make install && + sudo cp zstd.ini /etc/php/${{ matrix.php-versions }}/mods-available/ && + sudo phpenmod -v ${{ matrix.php-versions }} zstd + - name: Add Brotli extension (Windows) if: runner.os == 'Windows' && matrix.php-versions == '8.4' run: > curl -sSL -o brotli.zip https://github.com/kjdev/php-ext-brotli/releases/download/0.18.0/php_brotli-0.18.0-8.4-nts-vs17-x86_64.zip && unzip brotli.zip && - cp *.dll C:/tools/php/ext/php_brotli.dll && + cp php_brotli*.dll C:/tools/php/ext/php_brotli.dll && echo "extension=brotli" >> C:/tools/php/php.ini + - name: Add ZStandard extension (Windows) + if: runner.os == 'Windows' && matrix.php-versions == '8.4' + run: > + curl -sSL -o zstd.zip https://github.com/thekid/php-ext-zstd/releases/download/0.15.0/php_zstd-0.15.0-8.4-nts-vs17-x86_64.zip && + unzip zstd.zip && + cp php_zstd*.dll C:/tools/php/ext/php_zstd.dll && + echo "extension=zstd" >> C:/tools/php/php.ini + - name: Validate composer.json and composer.lock run: composer validate From 4aa8f3839744e7bd9409193c956f282a015ae1a8 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Thu, 31 Jul 2025 21:56:58 +0200 Subject: [PATCH 09/11] Add ChangeLog entry [skip ci] --- ChangeLog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index eaf8bdc..c9b6532 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -3,6 +3,10 @@ Compression streams ChangeLog ## ?.?.? / ????-??-?? +* Merged PR #8: Add ZStandard compression, based on the `zstd` extension + from https://github.com/kjdev/php-ext-zstd/ - see issue #7. + (@thekid) + ## 1.4.0 / 2025-07-31 * Merged PR #9: Add `Algorithm::compress()` and `Algorithm::decompress()` From df1d2a48066a4e4417c8404ccb2516b664319882 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 15 Aug 2025 20:23:44 +0200 Subject: [PATCH 10/11] Adapt to API changes for compression options --- src/main/php/io/streams/compress/ZStandard.class.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/php/io/streams/compress/ZStandard.class.php b/src/main/php/io/streams/compress/ZStandard.class.php index 06cfe46..8091795 100755 --- a/src/main/php/io/streams/compress/ZStandard.class.php +++ b/src/main/php/io/streams/compress/ZStandard.class.php @@ -24,8 +24,8 @@ public function level(int $select): int { } /** Compresses data */ - public function compress(string $data, int $level= Compression::DEFAULT): string { - return zstd_compress($data, $this->level($level)); + public function compress(string $data, $options= null): string { + return zstd_compress($data, $this->level(Options::from($options)->level)); } /** Decompresses bytes */ @@ -44,7 +44,7 @@ public function open(InputStream $in): InputStream { } /** Opens an output stream for writing */ - public function create(OutputStream $out, int $level= Compression::DEFAULT): OutputStream { - return new ZStandardOutputStream($out, $this->level($level)); + public function create(OutputStream $out, $options= null): OutputStream { + return new ZStandardOutputStream($out, $this->level(Options::from($options)->level)); } } \ No newline at end of file From 16bd7a1b3a17a5ce2572fdd6218900920b125c05 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 15 Aug 2025 20:26:08 +0200 Subject: [PATCH 11/11] Use official php_zstd.dll --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bb54d6..da70ebb 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: - name: Add ZStandard extension (Windows) if: runner.os == 'Windows' && matrix.php-versions == '8.4' run: > - curl -sSL -o zstd.zip https://github.com/thekid/php-ext-zstd/releases/download/0.15.0/php_zstd-0.15.0-8.4-nts-vs17-x86_64.zip && + curl -sSL -o zstd.zip https://github.com/kjdev/php-ext-zstd/releases/download/0.15.0/php_zstd-0.15.0-8.4-nts-vs17-x86_64.zip && unzip zstd.zip && cp php_zstd*.dll C:/tools/php/ext/php_zstd.dll && echo "extension=zstd" >> C:/tools/php/php.ini