├── .editorconfig
├── .phive
└── phars.xml
├── .php-cs-fixer.dist.php
├── .phpdoc
└── template
│ └── base.html.twig
├── .tool-versions
├── LICENSE
├── README.md
├── composer.json
├── guides
├── ContentLength.rst
├── FlySystem.rst
├── Nginx.rst
├── Options.rst
├── PSR7Streams.rst
├── StreamOutput.rst
├── Symfony.rst
├── Varnish.rst
└── index.rst
├── phpdoc.dist.xml
├── phpunit.xml.dist
├── psalm.xml
├── src
├── CentralDirectoryFileHeader.php
├── CompressionMethod.php
├── DataDescriptor.php
├── EndOfCentralDirectory.php
├── Exception.php
├── Exception
│ ├── DosTimeOverflowException.php
│ ├── FileNotFoundException.php
│ ├── FileNotReadableException.php
│ ├── FileSizeIncorrectException.php
│ ├── OverflowException.php
│ ├── ResourceActionException.php
│ ├── SimulationFileUnknownException.php
│ ├── StreamNotReadableException.php
│ └── StreamNotSeekableException.php
├── File.php
├── GeneralPurposeBitFlag.php
├── LocalFileHeader.php
├── OperationMode.php
├── PackField.php
├── Time.php
├── Version.php
├── Zip64
│ ├── DataDescriptor.php
│ ├── EndOfCentralDirectory.php
│ ├── EndOfCentralDirectoryLocator.php
│ └── ExtendedInformationExtraField.php
├── ZipStream.php
└── Zs
│ └── ExtendedInformationExtraField.php
└── test
├── Assertions.php
├── CentralDirectoryFileHeaderTest.php
├── DataDescriptorTest.php
├── EndOfCentralDirectoryTest.php
├── EndlessCycleStream.php
├── FaultInjectionResource.php
├── LocalFileHeaderTest.php
├── PackFieldTest.php
├── ResourceStream.php
├── Tempfile.php
├── TimeTest.php
├── Util.php
├── Zip64
├── DataDescriptorTest.php
├── EndOfCentralDirectoryLocatorTest.php
├── EndOfCentralDirectoryTest.php
└── ExtendedInformationExtraFieldTest.php
├── ZipStreamTest.php
├── Zs
└── ExtendedInformationExtraFieldTest.php
└── bootstrap.php
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | charset = utf-8
7 |
8 | [*.{yml,md,xml}]
9 | indent_style = space
10 | indent_size = 2
11 |
12 | [*.{rst,php}]
13 | indent_style = space
14 | indent_size = 4
15 |
16 | [composer.json]
17 | indent_style = space
18 | indent_size = 2
19 |
20 | [composer.lock]
21 | indent_style = space
22 | indent_size = 4
23 |
--------------------------------------------------------------------------------
/.phive/phars.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 |
8 | * @copyright 2022 Nicolas CARPi
9 | * @see https://github.com/maennchen/ZipStream-PHP
10 | * @license MIT
11 | * @package maennchen/ZipStream-PHP
12 | */
13 |
14 | use PhpCsFixer\Config;
15 | use PhpCsFixer\Finder;
16 | use PhpCsFixer\Runner;
17 |
18 | $finder = Finder::create()
19 | ->exclude('.github')
20 | ->exclude('.phpdoc')
21 | ->exclude('docs')
22 | ->exclude('tools')
23 | ->exclude('vendor')
24 | ->in(__DIR__);
25 |
26 | $config = new Config();
27 | return $config->setRules([
28 | '@PER' => true,
29 | '@PER:risky' => true,
30 | '@PHP83Migration' => true,
31 | '@PHP84Migration' => true,
32 | '@PHPUnit84Migration:risky' => true,
33 | 'array_syntax' => ['syntax' => 'short'],
34 | 'class_attributes_separation' => true,
35 | 'declare_strict_types' => true,
36 | 'dir_constant' => true,
37 | 'is_null' => true,
38 | 'no_homoglyph_names' => true,
39 | 'no_null_property_initialization' => true,
40 | 'no_php4_constructor' => true,
41 | 'no_unused_imports' => true,
42 | 'no_useless_else' => true,
43 | 'non_printable_character' => true,
44 | 'ordered_imports' => true,
45 | 'ordered_class_elements' => true,
46 | 'php_unit_construct' => true,
47 | 'pow_to_exponentiation' => true,
48 | 'psr_autoloading' => true,
49 | 'random_api_migration' => true,
50 | 'return_assignment' => true,
51 | 'self_accessor' => true,
52 | 'semicolon_after_instruction' => true,
53 | 'short_scalar_cast' => true,
54 | 'simplified_null_return' => true,
55 | 'single_class_element_per_statement' => true,
56 | 'single_line_comment_style' => true,
57 | 'single_quote' => true,
58 | 'space_after_semicolon' => true,
59 | 'standardize_not_equals' => true,
60 | 'strict_param' => true,
61 | 'ternary_operator_spaces' => true,
62 | 'trailing_comma_in_multiline' => true,
63 | 'trim_array_spaces' => true,
64 | 'unary_operator_spaces' => true,
65 | 'global_namespace_import' => [
66 | 'import_classes' => true,
67 | 'import_functions' => true,
68 | 'import_constants' => true,
69 | ],
70 | ])
71 | ->setFinder($finder)
72 | ->setRiskyAllowed(true)
73 | ->setParallelConfig(Runner\Parallel\ParallelConfigFactory::detect());
74 |
--------------------------------------------------------------------------------
/.phpdoc/template/base.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html.twig' %}
2 |
3 | {% set topMenu = {
4 | "menu": [
5 | { "name": "Guides", "url": "https://maennchen.dev/ZipStream-PHP/guide/index.html"},
6 | { "name": "API", "url": "https://maennchen.dev/ZipStream-PHP/classes/ZipStream-ZipStream.html"},
7 | { "name": "Issues", "url": "https://github.com/maennchen/ZipStream-PHP/issues"},
8 | ],
9 | "social": [
10 | { "iconClass": "fab fa-github", "url": "https://github.com/maennchen/ZipStream-PHP"},
11 | { "iconClass": "fas fa-envelope-open-text", "url": "https://github.com/maennchen/ZipStream-PHP/discussions"},
12 | { "iconClass": "fas fa-money-bill", "url": "https://github.com/sponsors/maennchen"},
13 | ]
14 | }
15 | %}
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | php 8.4.5
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (C) 2007-2009 Paul Duncan
4 | Copyright (C) 2014 Jonatan Männchen
5 | Copyright (C) 2014 Jesse G. Donat
6 | Copyright (C) 2018 Nicolas CARPi
7 |
8 | Permission is hereby granted, free of charge, to any person obtaining a copy
9 | of this software and associated documentation files (the "Software"), to deal
10 | in the Software without restriction, including without limitation the rights
11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 | copies of the Software, and to permit persons to whom the Software is
13 | furnished to do so, subject to the following conditions:
14 |
15 | The above copyright notice and this permission notice shall be included in all
16 | copies or substantial portions of the Software.
17 |
18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 | SOFTWARE.
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ZipStream-PHP
2 |
3 | [](https://github.com/maennchen/ZipStream-PHP/actions/workflows/branch_main.yml)
4 | [](https://coveralls.io/github/maennchen/ZipStream-PHP?branch=main)
5 | [](https://packagist.org/packages/maennchen/zipstream-php)
6 | [](https://packagist.org/packages/maennchen/zipstream-php)
7 | [](https://www.bestpractices.dev/projects/9524)
8 | [](https://scorecard.dev/viewer/?uri=github.com/maennchen/ZipStream-PHP)
9 |
10 | ## Unstable Branch
11 |
12 | The `main` branch is not stable. Please see the
13 | [releases](https://github.com/maennchen/ZipStream-PHP/releases) for a stable
14 | version.
15 |
16 | ## Overview
17 |
18 | A fast and simple streaming zip file downloader for PHP. Using this library will
19 | save you from having to write the Zip to disk. You can directly send it to the
20 | user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
21 |
22 | Please see the [LICENSE](LICENSE) file for licensing and warranty information.
23 |
24 | ## Installation
25 |
26 | Simply add a dependency on maennchen/zipstream-php to your project's
27 | `composer.json` file if you use Composer to manage the dependencies of your
28 | project. Use following command to add the package to your project's dependencies:
29 |
30 | ```bash
31 | composer require maennchen/zipstream-php
32 | ```
33 |
34 | ## Usage
35 |
36 | For detailed instructions, please check the
37 | [Documentation](https://maennchen.github.io/ZipStream-PHP/).
38 |
39 | ```php
40 | // Autoload the dependencies
41 | require 'vendor/autoload.php';
42 |
43 | // create a new zipstream object
44 | $zip = new ZipStream\ZipStream(
45 | outputName: 'example.zip',
46 |
47 | // enable output of HTTP headers
48 | sendHttpHeaders: true,
49 | );
50 |
51 | // create a file named 'hello.txt'
52 | $zip->addFile(
53 | fileName: 'hello.txt',
54 | data: 'This is the contents of hello.txt',
55 | );
56 |
57 | // add a file named 'some_image.jpg' from a local file 'path/to/image.jpg'
58 | $zip->addFileFromPath(
59 | fileName: 'some_image.jpg',
60 | path: 'path/to/image.jpg',
61 | );
62 |
63 | // finish the zip stream
64 | $zip->finish();
65 | ```
66 |
67 | ## Questions
68 |
69 | **💬 Questions? Please Read This First!**
70 |
71 | If you have a question about using this library, please *do not email the
72 | authors directly*. Instead, head over to the
73 | [GitHub Discussions](https://github.com/maennchen/ZipStream-PHP/discussions)
74 | page — your question might already be answered there! Using Discussions helps
75 | build a shared knowledge base, so others can also benefit from the answers. If
76 | you need dedicated 1:1 support, check out the options available on
77 | [@maennchen's sponsorship page](https://github.com/sponsors/maennchen?frequency=one-time&sponsor=maennchen).
78 |
79 | ## Upgrade to version 3.1.2
80 |
81 | - Minimum PHP Version: `8.2`
82 |
83 | ## Upgrade to version 3.0.0
84 |
85 | ### General
86 |
87 | - Minimum PHP Version: `8.1`
88 | - Only 64bit Architecture is supported.
89 | - The class `ZipStream\Option\Method` has been replaced with the enum
90 | `ZipStream\CompressionMethod`.
91 | - Most classes have been flagged as `@internal` and should not be used from the
92 | outside.
93 | If you're using internal resources to extend this library, please open an
94 | issue so that a clean interface can be added & published.
95 | The externally available classes & enums are:
96 | - `ZipStream\CompressionMethod`
97 | - `ZipStream\Exception*`
98 | - `ZipStream\ZipStream`
99 |
100 | ### Archive Options
101 |
102 | - The class `ZipStream\Option\Archive` has been replaced in favor of named
103 | arguments in the `ZipStream\ZipStream` constructor.
104 | - The archive options `largeFileSize` & `largeFileMethod` has been removed. If
105 | you want different `compressionMethods` based on the file size, you'll have to
106 | implement this yourself.
107 | - The archive option `httpHeaderCallback` changed the type from `callable` to
108 | `Closure`.
109 | - The archive option `zeroHeader` has been replaced with the option
110 | `defaultEnableZeroHeader` and can be overridden for every file. Its default
111 | value changed from `false` to `true`.
112 | - The archive option `statFiles` was removed since the library no longer checks
113 | filesizes this way.
114 | - The archive option `deflateLevel` has been replaced with the option
115 | `defaultDeflateLevel` and can be overridden for every file.
116 | - The first argument (`name`) of the `ZipStream\ZipStream` constructor has been
117 | replaced with the named argument `outputName`.
118 | - Headers are now also sent if the `outputName` is empty. If you do not want to
119 | automatically send http headers, set `sendHttpHeaders` to `false`.
120 |
121 | ### File Options
122 |
123 | - The class `ZipStream\Option\File` has been replaced in favor of named
124 | arguments in the `ZipStream\ZipStream->addFile*` functions.
125 | - The file option `method` has been renamed to `compressionMethod`.
126 | - The file option `time` has been renamed to `lastModificationDateTime`.
127 | - The file option `size` has been renamed to `maxSize`.
128 |
129 | ## Upgrade to version 2.0.0
130 |
131 | https://github.com/maennchen/ZipStream-PHP/tree/2.0.0#upgrade-to-version-200
132 |
133 | ## Upgrade to version 1.0.0
134 |
135 | https://github.com/maennchen/ZipStream-PHP/tree/2.0.0#upgrade-to-version-100
136 |
137 | ## Contributing
138 |
139 | ZipStream-PHP is a collaborative project. Please take a look at the
140 | [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) file.
141 |
142 | ## Version Support
143 |
144 | Versions are supported according to the table below.
145 |
146 | Please do not open any pull requests contradicting the current version support
147 | status.
148 |
149 | Careful: Always check the `README` on `main` for up-to-date information.
150 |
151 | | Version | New Features | Bugfixes | Security |
152 | |---------|--------------|----------|----------|
153 | | *3* | ✓ | ✓ | ✓ |
154 | | *2* | ✗ | ✗ | ✓ |
155 | | *1* | ✗ | ✗ | ✗ |
156 | | *0* | ✗ | ✗ | ✗ |
157 |
158 | This library aligns itself with the PHP core support. New features and bugfixes
159 | will only target PHP versions according to their current status.
160 |
161 | See: https://www.php.net/supported-versions.php
162 |
163 | ## About the Authors
164 |
165 | - Paul Duncan - https://pablotron.org/
166 | - Jonatan Männchen - https://maennchen.dev
167 | - Jesse G. Donat - https://donatstudios.com
168 | - Nicolas CARPi - https://www.deltablot.com
169 | - Nik Barham - https://www.brokencube.co.uk
170 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "maennchen/zipstream-php",
3 | "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
4 | "keywords": ["zip", "stream"],
5 | "type": "library",
6 | "license": "MIT",
7 | "authors": [{
8 | "name": "Paul Duncan",
9 | "email": "pabs@pablotron.org"
10 | },
11 | {
12 | "name": "Jonatan Männchen",
13 | "email": "jonatan@maennchen.ch"
14 | },
15 | {
16 | "name": "Jesse Donat",
17 | "email": "donatj@gmail.com"
18 | },
19 | {
20 | "name": "András Kolesár",
21 | "email": "kolesar@kolesar.hu"
22 | }
23 | ],
24 | "require": {
25 | "php-64bit": "^8.3",
26 | "ext-mbstring": "*",
27 | "ext-zlib": "*"
28 | },
29 | "require-dev": {
30 | "phpunit/phpunit": "^12.0",
31 | "guzzlehttp/guzzle": "^7.5",
32 | "ext-zip": "*",
33 | "mikey179/vfsstream": "^1.6",
34 | "php-coveralls/php-coveralls": "^2.5",
35 | "friendsofphp/php-cs-fixer": "^3.16",
36 | "vimeo/psalm": "^6.0",
37 | "brianium/paratest": "^7.7"
38 | },
39 | "suggest": {
40 | "psr/http-message": "^2.0",
41 | "guzzlehttp/psr7": "^2.4"
42 | },
43 | "scripts": {
44 | "format": "php-cs-fixer fix",
45 | "test": [
46 | "@test:unit",
47 | "@test:formatted",
48 | "@test:lint"
49 | ],
50 | "test:unit:setup-cov": "@putenv XDEBUG_MODE=coverage",
51 | "test:unit": "paratest --functional",
52 | "test:unit:cov": ["@test:unit:setup-cov", "@test:unit --coverage-clover=coverage.clover.xml --coverage-html cov"],
53 | "test:unit:slow": "@test:unit --group slow",
54 | "test:unit:slow:cov": ["@test:unit:setup-cov", "@test:unit --coverage-clover=coverage.clover.xml --coverage-html cov --group slow"],
55 | "test:unit:fast": "@test:unit --exclude-group slow",
56 | "test:unit:fast:cov": ["@test:unit:setup-cov", "@test:unit --coverage-clover=coverage.clover.xml --coverage-html cov --exclude-group slow"],
57 | "test:formatted": "@format --dry-run --stop-on-violation --using-cache=no",
58 | "test:lint": "psalm --stats --show-info=true --find-unused-psalm-suppress",
59 | "coverage:report": "php-coveralls --coverage_clover=coverage.clover.xml --json_path=coveralls-upload.json --insecure",
60 | "install:tools": "phive install --trust-gpg-keys 0x67F861C3D889C656 --trust-gpg-keys 0x8AC0BAA79732DD42 --trust-gpg-keys 0x6DA3ACC4991FFAE5",
61 | "docs:generate": "tools/phpdocumentor --sourcecode"
62 | },
63 | "autoload": {
64 | "psr-4": {
65 | "ZipStream\\": "src/"
66 | }
67 | },
68 | "autoload-dev": {
69 | "psr-4": { "ZipStream\\Test\\": "test/" }
70 | },
71 | "archive": {
72 | "exclude": [
73 | "/composer.lock",
74 | "/docs",
75 | "/.gitattributes",
76 | "/.github",
77 | "/.gitignore",
78 | "/guides",
79 | "/.phive",
80 | "/.php-cs-fixer.cache",
81 | "/.php-cs-fixer.dist.php",
82 | "/.phpdoc",
83 | "/phpdoc.dist.xml",
84 | "/.phpunit.result.cache",
85 | "/phpunit.xml.dist",
86 | "/psalm.xml",
87 | "/test",
88 | "/tools",
89 | "/.tool-versions",
90 | "/vendor"
91 | ]
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/guides/ContentLength.rst:
--------------------------------------------------------------------------------
1 | Adding Content-Length header
2 | =============
3 |
4 | Adding a ``Content-Length`` header for ``ZipStream`` can be achieved by
5 | using the options ``SIMULATION_STRICT`` or ``SIMULATION_LAX`` in the
6 | ``operationMode`` parameter.
7 |
8 | In the ``SIMULATION_STRICT`` mode, ``ZipStream`` will not allow to calculate the
9 | size based on reading the whole file. ``SIMULATION_LAX`` will read the whole
10 | file if necessary.
11 |
12 | ``SIMULATION_STRICT`` is therefore useful to make sure that the size can be
13 | calculated efficiently.
14 |
15 | .. code-block:: php
16 | use ZipStream\OperationMode;
17 | use ZipStream\ZipStream;
18 |
19 | $zip = new ZipStream(
20 | operationMode: OperationMode::SIMULATE_STRICT, // or SIMULATE_LAX
21 | defaultEnableZeroHeader: false,
22 | sendHttpHeaders: true,
23 | outputStream: $stream,
24 | );
25 |
26 | // Normally add files
27 | $zip->addFile('sample.txt', 'Sample String Data');
28 |
29 | // Use addFileFromCallback and exactSize if you want to defer opening of
30 | // the file resource
31 | $zip->addFileFromCallback(
32 | 'sample.txt',
33 | exactSize: 18,
34 | callback: function () {
35 | return fopen('...');
36 | }
37 | );
38 |
39 | // Read resulting file size
40 | $size = $zip->finish();
41 |
42 | // Tell it to the browser
43 | header('Content-Length: '. $size);
44 |
45 | // Execute the Simulation and stream the actual zip to the client
46 | $zip->executeSimulation();
47 |
48 |
--------------------------------------------------------------------------------
/guides/FlySystem.rst:
--------------------------------------------------------------------------------
1 | Usage with FlySystem
2 | ===============
3 |
4 | For saving or uploading the generated zip, you can use the
5 | `Flysystem `_ package, and its many
6 | adapters.
7 |
8 | For that you will need to provide another stream than the ``php://output``
9 | default one, and pass it to Flysystem ``putStream`` method.
10 |
11 | .. code-block:: php
12 |
13 | // Open Stream only once for read and write since it's a memory stream and
14 | // the content is lost when closing the stream / opening another one
15 | $tempStream = fopen('php://memory', 'w+');
16 |
17 | // Create Zip Archive
18 | $zipStream = new ZipStream(
19 | outputStream: $tempStream,
20 | outputName: 'test.zip',
21 | );
22 | $zipStream->addFile('test.txt', 'text');
23 | $zipStream->finish();
24 |
25 | // Store File
26 | // (see Flysystem documentation, and all its framework integration)
27 | // Can be any adapter (AWS, Google, Ftp, etc.)
28 | $adapter = new Local(__DIR__.'/path/to/folder');
29 | $filesystem = new Filesystem($adapter);
30 |
31 | $filesystem->writeStream('test.zip', $tempStream)
32 |
33 | // Close Stream
34 | fclose($tempStream);
35 |
--------------------------------------------------------------------------------
/guides/Nginx.rst:
--------------------------------------------------------------------------------
1 | Usage with nginx
2 | =============
3 |
4 | If you are using nginx as a webserver, it will try to buffer the response.
5 | So you'll want to disable this with a custom header:
6 |
7 | .. code-block:: php
8 | header('X-Accel-Buffering: no');
9 | # or with the Response class from Symfony
10 | $response->headers->set('X-Accel-Buffering', 'no');
11 |
12 | Alternatively, you can tweak the
13 | `fastcgi cache parameters `_
14 | within nginx config.
15 |
16 | See `original issue `_.
--------------------------------------------------------------------------------
/guides/Options.rst:
--------------------------------------------------------------------------------
1 | Available options
2 | ===============
3 |
4 | Here is the full list of options available to you. You can also have a look at
5 | ``src/ZipStream.php`` file.
6 |
7 | .. code-block:: php
8 |
9 | use ZipStream\ZipStream;
10 |
11 | require_once 'vendor/autoload.php';
12 |
13 | $zip = new ZipStream(
14 | // Define output stream
15 | // (argument is either a resource or implementing
16 | // `Psr\Http\Message\StreamInterface`)
17 | //
18 | // Setup with `psr/http-message` & `guzzlehttp/psr7` dependencies
19 | // required when using `Psr\Http\Message\StreamInterface`.
20 | outputStream: $filePointer,
21 |
22 | // Set the deflate level (default is 6; use -1 to disable it)
23 | defaultDeflateLevel: 6,
24 |
25 | // Add a comment to the zip file
26 | comment: 'This is a comment.',
27 |
28 | // Send http headers (default is true)
29 | sendHttpHeaders: false,
30 |
31 | // HTTP Content-Disposition.
32 | // Defaults to 'attachment', where FILENAME is the specified filename.
33 | // Note that this does nothing if you are not sending HTTP headers.
34 | contentDisposition: 'attachment',
35 |
36 | // Output Name for HTTP Content-Disposition
37 | // Defaults to no name
38 | outputName: "example.zip",
39 |
40 | // HTTP Content-Type.
41 | // Defaults to 'application/x-zip'.
42 | // Note that this does nothing if you are not sending HTTP headers.
43 | contentType: 'application/x-zip',
44 |
45 | // Set the function called for setting headers.
46 | // Default is the `header()` of PHP
47 | httpHeaderCallback: header(...),
48 |
49 | // Enable streaming files with single read where general purpose bit 3
50 | // indicates local file header contain zero values in crc and size
51 | // fields, these appear only after file contents in data descriptor
52 | // block.
53 | // Set to true if your input stream is remote
54 | // (used with addFileFromStream()).
55 | // Default is false.
56 | defaultEnableZeroHeader: false,
57 |
58 | // Enable zip64 extension, allowing very large archives
59 | // (> 4Gb or file count > 64k)
60 | // Default is true
61 | enableZip64: true,
62 |
63 | // Flush output buffer after every write
64 | // Default is false
65 | flushOutput: true,
66 | );
67 |
--------------------------------------------------------------------------------
/guides/PSR7Streams.rst:
--------------------------------------------------------------------------------
1 | Usage with PSR 7 Streams
2 | ===============
3 |
4 | PSR-7 streams are `standardized streams `_.
5 |
6 | ZipStream-PHP supports working with these streams with the function
7 | ``addFileFromPsr7Stream``.
8 |
9 | For all parameters of the function see the API documentation.
10 |
11 | Example
12 | ---------------
13 |
14 | .. code-block:: php
15 |
16 | $stream = $response->getBody();
17 | // add a file named 'streamfile.txt' from the content of the stream
18 | $zip->addFileFromPsr7Stream(
19 | fileName: 'streamfile.txt',
20 | stream: $stream,
21 | );
22 |
--------------------------------------------------------------------------------
/guides/StreamOutput.rst:
--------------------------------------------------------------------------------
1 | Stream Output
2 | ===============
3 |
4 | Stream to S3 Bucket
5 | ---------------
6 |
7 | .. code-block:: php
8 |
9 | use Aws\S3\S3Client;
10 | use Aws\Credentials\CredentialProvider;
11 | use ZipStream\ZipStream;
12 |
13 | $bucket = 'your bucket name';
14 | $client = new S3Client([
15 | 'region' => 'your region',
16 | 'version' => 'latest',
17 | 'bucketName' => $bucket,
18 | 'credentials' => CredentialProvider::defaultProvider(),
19 | ]);
20 | $client->registerStreamWrapper();
21 |
22 | $zipFile = fopen("s3://$bucket/example.zip", 'w');
23 |
24 | $zip = new ZipStream(
25 | enableZip64: false,
26 | outputStream: $zipFile,
27 | );
28 |
29 | $zip->addFile(
30 | fileName: 'file1.txt',
31 | data: 'File1 data',
32 | );
33 | $zip->addFile(
34 | fileName: 'file2.txt',
35 | data: 'File2 data',
36 | );
37 | $zip->finish();
38 |
39 | fclose($zipFile);
40 |
--------------------------------------------------------------------------------
/guides/Symfony.rst:
--------------------------------------------------------------------------------
1 | Usage with Symfony
2 | ===============
3 |
4 | Overview for using ZipStream in Symfony
5 | --------
6 |
7 | Using ZipStream in Symfony requires use of Symfony's ``StreamedResponse`` when
8 | used in controller actions.
9 |
10 | Wrap your call to the relevant ``ZipStream`` stream method (i.e. ``addFile``,
11 | ``addFileFromPath``, ``addFileFromStream``) in Symfony's ``StreamedResponse``
12 | function passing in any required arguments for your use case.
13 |
14 | Using Symfony's ``StreamedResponse`` will allow Symfony to stream output from
15 | ZipStream correctly to users' browsers and avoid a corrupted final zip landing
16 | on the users' end.
17 |
18 | Example for using ``ZipStream`` in a controller action to zip stream files
19 | stored in an AWS S3 bucket by key:
20 |
21 | .. code-block:: php
22 |
23 | use Symfony\Component\HttpFoundation\StreamedResponse;
24 | use Aws\S3\S3Client;
25 | use ZipStream;
26 |
27 | //...
28 |
29 | /**
30 | * @Route("/zipstream", name="zipstream")
31 | */
32 | public function zipStreamAction()
33 | {
34 | // sample test file on s3
35 | $s3keys = array(
36 | "ziptestfolder/file1.txt"
37 | );
38 |
39 | $s3Client = $this->get('app.amazon.s3'); //s3client service
40 | $s3Client->registerStreamWrapper(); //required
41 |
42 | // using StreamedResponse to wrap ZipStream functionality
43 | // for files on AWS s3.
44 | $response = new StreamedResponse(function() use($s3keys, $s3Client)
45 | {
46 | // Define suitable options for ZipStream Archive.
47 | // this is needed to prevent issues with truncated zip files
48 | //initialise zipstream with output zip filename and options.
49 | $zip = new ZipStream\ZipStream(
50 | outputName: 'test.zip',
51 | defaultEnableZeroHeader: true,
52 | contentType: 'application/octet-stream',
53 | );
54 |
55 | //loop keys - useful for multiple files
56 | foreach ($s3keys as $key) {
57 | // Get the file name in S3 key so we can save it to the zip
58 | //file using the same name.
59 | $fileName = basename($key);
60 |
61 | // concatenate s3path.
62 | // replace with your bucket name or get from parameters file.
63 | $bucket = 'bucketname';
64 | $s3path = "s3://" . $bucket . "/" . $key;
65 |
66 | //addFileFromStream
67 | if ($streamRead = fopen($s3path, 'r')) {
68 | $zip->addFileFromStream(
69 | fileName: $fileName,
70 | stream: $streamRead,
71 | );
72 | } else {
73 | die('Could not open stream for reading');
74 | }
75 | }
76 |
77 | $zip->finish();
78 |
79 | });
80 |
81 | return $response;
82 | }
83 |
84 | In the above example, files on AWS S3 are being streamed from S3 to the Symfon
85 | application via ``fopen`` call when the s3Client has ``registerStreamWrapper``
86 | applied. This stream is then passed to ``ZipStream`` via the
87 | ``addFileFromStream`` function, which ZipStream then streams as a zip to the
88 | client browser via Symfony's ``StreamedResponse``. No Zip is created server
89 | side, which makes this approach a more efficient solution for streaming zips to
90 | the client browser especially for larger files.
91 |
92 | For the above use case you will need to have installed
93 | `aws/aws-sdk-php-symfony `_ to
94 | support accessing S3 objects in your Symfony web application. This is not
95 | required for locally stored files on you server you intend to stream via
96 | ``ZipStream``.
97 |
98 | See official Symfony documentation for details on
99 | `Symfony's StreamedResponse `_
100 | ``Symfony\Component\HttpFoundation\StreamedResponse``.
101 |
102 | Note from `S3 documentation `_:
103 |
104 | Streams opened in "r" mode only allow data to be read from the stream, and
105 | are not seekable by default. This is so that data can be downloaded from
106 | Amazon S3 in a truly streaming manner, where previously read bytes do not
107 | need to be buffered into memory. If you need a stream to be seekable, you
108 | can pass seekable into the stream context options of a function.
109 |
110 | Make sure to configure your S3 context correctly!
111 |
112 | Uploading a file
113 | --------
114 |
115 | You need to add correct permissions
116 | (see `#120 `_)
117 |
118 | **example code**
119 |
120 |
121 | .. code-block:: php
122 |
123 | $path = "s3://{$adapter->getBucket()}/{$this->getArchivePath()}";
124 |
125 | // the important bit
126 | $outputContext = stream_context_create([
127 | 's3' => ['ACL' => 'public-read'],
128 | ]);
129 |
130 | fopen($path, 'w', null, $outputContext);
131 |
--------------------------------------------------------------------------------
/guides/Varnish.rst:
--------------------------------------------------------------------------------
1 | Usage with Varnish
2 | =============
3 |
4 | Serving a big zip with varnish in between can cause random stream close.
5 | This can be solved by adding attached code to the vcl file.
6 |
7 | To avoid the problem, add the following to your varnish config file:
8 |
9 | .. code-block::
10 | sub vcl_recv {
11 | # Varnish can’t intercept the discussion anymore
12 | # helps for streaming big zips
13 | if (req.url ~ "\.(tar|gz|zip|7z|exe)$") {
14 | return (pipe);
15 | }
16 | }
17 | # Varnish can’t intercept the discussion anymore
18 | # helps for streaming big zips
19 | sub vcl_pipe {
20 | set bereq.http.connection = "close";
21 | return (pipe);
22 | }
23 |
--------------------------------------------------------------------------------
/guides/index.rst:
--------------------------------------------------------------------------------
1 | ZipStream PHP
2 | =============
3 |
4 | A fast and simple streaming zip file downloader for PHP. Using this library will
5 | save you from having to write the Zip to disk. You can directly send it to the
6 | user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
7 |
8 | .. toctree::
9 |
10 | index
11 | Symfony
12 | Options
13 | StreamOutput
14 | FlySystem
15 | PSR7Streams
16 | Nginx
17 | Varnish
18 | ContentLength
19 |
20 | Installation
21 | ---------------
22 |
23 | Simply add a dependency on ``maennchen/zipstream-php`` to your project's
24 | ``composer.json`` file if you use Composer to manage the dependencies of your
25 | project. Use following command to add the package to your project's
26 | dependencies:
27 |
28 | .. code-block:: sh
29 | composer require maennchen/zipstream-php
30 |
31 | If you want to use``addFileFromPsr7Stream```
32 | (``Psr\Http\Message\StreamInterface``) or use a stream instead of a
33 | ``resource`` as ``outputStream``, the following dependencies must be installed
34 | as well:
35 |
36 | .. code-block:: sh
37 | composer require psr/http-message guzzlehttp/psr7
38 |
39 | If ``composer install`` yields the following error, your installation is missing
40 | the `mbstring extension `_,
41 | either `install it `_
42 | or run the following command:
43 |
44 | .. code-block::
45 | Your requirements could not be resolved to an installable set of packages.
46 |
47 | Problem 1
48 | - Root composer.json requires PHP extension ext-mbstring * but it is
49 | missing from your system. Install or enable PHP's mbstrings extension.
50 |
51 | .. code-block:: sh
52 | composer require symfony/polyfill-mbstring
53 |
54 | Usage Intro
55 | ---------------
56 |
57 | Here's a simple example:
58 |
59 | .. code-block:: php
60 |
61 | // Autoload the dependencies
62 | require 'vendor/autoload.php';
63 |
64 | // create a new zipstream object
65 | $zip = new ZipStream\ZipStream(
66 | outputName: 'example.zip',
67 |
68 | // enable output of HTTP headers
69 | sendHttpHeaders: true,
70 | );
71 |
72 | // create a file named 'hello.txt'
73 | $zip->addFile(
74 | fileName: 'hello.txt',
75 | data: 'This is the contents of hello.txt',
76 | );
77 |
78 | // add a file named 'some_image.jpg' from a local file 'path/to/image.jpg'
79 | $zip->addFileFromPath(
80 | fileName: 'some_image.jpg',
81 | path: 'path/to/image.jpg',
82 | );
83 |
84 | // add a file named 'goodbye.txt' from an open stream resource
85 | $filePointer = tmpfile();
86 | fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');
87 | rewind($filePointer);
88 | $zip->addFileFromStream(
89 | fileName: 'goodbye.txt',
90 | stream: $filePointer,
91 | );
92 | fclose($filePointer);
93 |
94 | // add a file named 'streamfile.txt' from the body of a `guzzle` response
95 | // Setup with `psr/http-message` & `guzzlehttp/psr7` dependencies required.
96 | $zip->addFileFromPsr7Stream(
97 | fileName: 'streamfile.txt',
98 | stream: $response->getBody(),
99 | );
100 |
101 | // finish the zip stream
102 | $zip->finish();
103 |
104 | You can also add comments, modify file timestamps, and customize (or
105 | disable) the HTTP headers. It is also possible to specify the storage method
106 | when adding files, the current default storage method is ``DEFLATE``
107 | i.e files are stored with Compression mode 0x08.
108 |
109 | Known Issues
110 | ---------------
111 |
112 | The native Mac OS archive extraction tool prior to macOS 10.15 might not open
113 | archives in some conditions. A workaround is to disable the Zip64 feature with
114 | the option ``enableZip64: false``. This limits the archive to 4 Gb and 64k files
115 | but will allow users on macOS 10.14 and below to open them without issue.
116 | See `#116 `_.
117 |
118 | The linux ``unzip`` utility might not handle properly unicode characters.
119 | It is recommended to extract with another tool like
120 | `7-zip `_.
121 | See `#146 `_.
122 |
123 | It is the responsibility of the client code to make sure that files are not
124 | saved with the same path, as it is not possible for the library to figure it out
125 | while streaming a zip.
126 | See `#154 `_.
127 |
--------------------------------------------------------------------------------
/phpdoc.dist.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 | 💾 ZipStream-PHP
9 |
10 |
11 |
12 |
13 | latest
14 |
15 |
16 | src
17 |
18 |
19 |
20 | tests/**/*
21 | vendor/**/*
22 |
23 |
24 | php
25 |
26 | public
27 | ZipStream
28 | true
29 |
30 |
31 |
32 | guides
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | test
7 |
8 |
9 |
10 |
11 |
12 | src
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/CentralDirectoryFileHeader.php:
--------------------------------------------------------------------------------
1 | value),
39 | new PackField(format: 'V', value: Time::dateTimeToDosTime($lastModificationDateTime)),
40 | new PackField(format: 'V', value: $crc32),
41 | new PackField(format: 'V', value: $compressedSize),
42 | new PackField(format: 'V', value: $uncompressedSize),
43 | new PackField(format: 'v', value: strlen($fileName)),
44 | new PackField(format: 'v', value: strlen($extraField)),
45 | new PackField(format: 'v', value: strlen($fileComment)),
46 | new PackField(format: 'v', value: $diskNumberStart),
47 | new PackField(format: 'v', value: $internalFileAttributes),
48 | new PackField(format: 'V', value: $externalFileAttributes),
49 | new PackField(format: 'V', value: $relativeOffsetOfLocalHeader),
50 | ) . $fileName . $extraField . $fileComment;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/CompressionMethod.php:
--------------------------------------------------------------------------------
1 | format(DateTimeInterface::ATOM) . " can't be represented as DOS time / date.");
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Exception/FileNotFoundException.php:
--------------------------------------------------------------------------------
1 | resource = $resource;
29 | parent::__construct('Function ' . $function . 'failed on resource.');
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Exception/SimulationFileUnknownException.php:
--------------------------------------------------------------------------------
1 | fileName = self::filterFilename($fileName);
63 | $this->checkEncoding();
64 |
65 | if ($this->enableZeroHeader) {
66 | $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER;
67 | }
68 |
69 | $this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE;
70 | }
71 |
72 | public function cloneSimulationExecution(): self
73 | {
74 | return new self(
75 | $this->fileName,
76 | $this->dataCallback,
77 | OperationMode::NORMAL,
78 | $this->startOffset,
79 | $this->compressionMethod,
80 | $this->comment,
81 | $this->lastModificationDateTime,
82 | $this->deflateLevel,
83 | $this->maxSize,
84 | $this->exactSize,
85 | $this->enableZip64,
86 | $this->enableZeroHeader,
87 | $this->send,
88 | $this->recordSentBytes,
89 | );
90 | }
91 |
92 | public function process(): string
93 | {
94 | $forecastSize = $this->forecastSize();
95 |
96 | if ($this->enableZeroHeader) {
97 | // No calculation required
98 | } elseif ($this->isSimulation() && $forecastSize !== null) {
99 | $this->uncompressedSize = $forecastSize;
100 | $this->compressedSize = $forecastSize;
101 | } else {
102 | $this->readStream(send: false);
103 | if (rewind($this->unpackStream()) === false) {
104 | throw new ResourceActionException('rewind', $this->unpackStream());
105 | }
106 | }
107 |
108 | $this->addFileHeader();
109 |
110 | $detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
111 |
112 | if (
113 | $this->isSimulation() &&
114 | $detectedSize !== null
115 | ) {
116 | $this->uncompressedSize = $detectedSize;
117 | $this->compressedSize = $detectedSize;
118 | ($this->recordSentBytes)($detectedSize);
119 | } else {
120 | $this->readStream(send: true);
121 | }
122 |
123 | $this->addFileFooter();
124 | return $this->getCdrFile();
125 | }
126 |
127 | /**
128 | * @return resource
129 | */
130 | private function unpackStream()
131 | {
132 | if ($this->stream) {
133 | return $this->stream;
134 | }
135 |
136 | if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
137 | throw new SimulationFileUnknownException();
138 | }
139 |
140 | $this->stream = ($this->dataCallback)();
141 |
142 | if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
143 | throw new StreamNotSeekableException();
144 | }
145 | if (!(
146 | str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
147 | || str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
148 | || str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
149 | || str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
150 | || str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
151 | )) {
152 | throw new StreamNotReadableException();
153 | }
154 |
155 | return $this->stream;
156 | }
157 |
158 | private function forecastSize(): ?int
159 | {
160 | if ($this->compressionMethod !== CompressionMethod::STORE) {
161 | return null;
162 | }
163 | if ($this->exactSize !== null) {
164 | return $this->exactSize;
165 | }
166 | $fstat = fstat($this->unpackStream());
167 | if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
168 | return null;
169 | }
170 |
171 | if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
172 | return $this->maxSize;
173 | }
174 |
175 | return $fstat['size'];
176 | }
177 |
178 | /**
179 | * Create and send zip header for this file.
180 | */
181 | private function addFileHeader(): void
182 | {
183 | $forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64;
184 |
185 | $footer = $this->buildZip64ExtraBlock($forceEnableZip64);
186 |
187 | $zip64Enabled = $footer !== '';
188 |
189 | if ($zip64Enabled) {
190 | $this->version = Version::ZIP64;
191 | }
192 |
193 | if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) {
194 | // Put the tricky entry to
195 | // force Linux unzip to lookup EFS flag.
196 | $footer .= Zs\ExtendedInformationExtraField::generate();
197 | }
198 |
199 | $data = LocalFileHeader::generate(
200 | versionNeededToExtract: $this->version->value,
201 | generalPurposeBitFlag: $this->generalPurposeBitFlag,
202 | compressionMethod: $this->compressionMethod,
203 | lastModificationDateTime: $this->lastModificationDateTime,
204 | crc32UncompressedData: $this->crc,
205 | compressedSize: $zip64Enabled
206 | ? 0xFFFFFFFF
207 | : $this->compressedSize,
208 | uncompressedSize: $zip64Enabled
209 | ? 0xFFFFFFFF
210 | : $this->uncompressedSize,
211 | fileName: $this->fileName,
212 | extraField: $footer,
213 | );
214 |
215 |
216 | ($this->send)($data);
217 | }
218 |
219 | /**
220 | * Strip characters that are not legal in Windows filenames
221 | * to prevent compatibility issues
222 | */
223 | private static function filterFilename(
224 | /**
225 | * Unprocessed filename
226 | */
227 | string $fileName
228 | ): string {
229 | // strip leading slashes from file name
230 | // (fixes bug in windows archive viewer)
231 | $fileName = ltrim($fileName, '/');
232 |
233 | return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName);
234 | }
235 |
236 | private function checkEncoding(): void
237 | {
238 | // Sets Bit 11: Language encoding flag (EFS). If this bit is set,
239 | // the filename and comment fields for this file
240 | // MUST be encoded using UTF-8. (see APPENDIX D)
241 | if (mb_check_encoding($this->fileName, 'UTF-8') &&
242 | mb_check_encoding($this->comment, 'UTF-8')) {
243 | $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
244 | }
245 | }
246 |
247 | private function buildZip64ExtraBlock(bool $force = false): string
248 | {
249 | $outputZip64ExtraBlock = false;
250 |
251 | $originalSize = null;
252 | if ($force || $this->uncompressedSize > 0xFFFFFFFF) {
253 | $outputZip64ExtraBlock = true;
254 | $originalSize = $this->uncompressedSize;
255 | }
256 |
257 | $compressedSize = null;
258 | if ($force || $this->compressedSize > 0xFFFFFFFF) {
259 | $outputZip64ExtraBlock = true;
260 | $compressedSize = $this->compressedSize;
261 | }
262 |
263 | // If this file will start over 4GB limit in ZIP file,
264 | // CDR record will have to use Zip64 extension to describe offset
265 | // to keep consistency we use the same value here
266 | $relativeHeaderOffset = null;
267 | if ($this->startOffset > 0xFFFFFFFF) {
268 | $outputZip64ExtraBlock = true;
269 | $relativeHeaderOffset = $this->startOffset;
270 | }
271 |
272 | if (!$outputZip64ExtraBlock) {
273 | return '';
274 | }
275 |
276 | if (!$this->enableZip64) {
277 | throw new OverflowException();
278 | }
279 |
280 | return Zip64\ExtendedInformationExtraField::generate(
281 | originalSize: $originalSize,
282 | compressedSize: $compressedSize,
283 | relativeHeaderOffset: $relativeHeaderOffset,
284 | diskStartNumber: null,
285 | );
286 | }
287 |
288 | private function addFileFooter(): void
289 | {
290 | if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) {
291 | throw new OverflowException();
292 | }
293 |
294 | if (!$this->enableZeroHeader) {
295 | return;
296 | }
297 |
298 | if ($this->version === Version::ZIP64) {
299 | $footer = Zip64\DataDescriptor::generate(
300 | crc32UncompressedData: $this->crc,
301 | compressedSize: $this->compressedSize,
302 | uncompressedSize: $this->uncompressedSize,
303 | );
304 | } else {
305 | $footer = DataDescriptor::generate(
306 | crc32UncompressedData: $this->crc,
307 | compressedSize: $this->compressedSize,
308 | uncompressedSize: $this->uncompressedSize,
309 | );
310 | }
311 |
312 | ($this->send)($footer);
313 | }
314 |
315 | private function readStream(bool $send): void
316 | {
317 | $this->compressedSize = 0;
318 | $this->uncompressedSize = 0;
319 | $hash = hash_init('crc32b');
320 |
321 | $deflate = $this->compressionInit();
322 |
323 | while (
324 | !feof($this->unpackStream()) &&
325 | ($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
326 | ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
327 | ) {
328 | $readLength = min(
329 | ($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
330 | ($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
331 | self::CHUNKED_READ_BLOCK_SIZE
332 | );
333 |
334 | $data = fread($this->unpackStream(), $readLength);
335 |
336 | if ($data === false) {
337 | throw new ResourceActionException('fread', $this->unpackStream());
338 | }
339 |
340 | hash_update($hash, $data);
341 |
342 | $this->uncompressedSize += strlen($data);
343 |
344 | if ($deflate) {
345 | $data = deflate_add(
346 | $deflate,
347 | $data,
348 | feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
349 | );
350 |
351 | if ($data === false) {
352 | throw new RuntimeException('deflate_add failed');
353 | }
354 | }
355 |
356 | $this->compressedSize += strlen($data);
357 |
358 | if ($send) {
359 | ($this->send)($data);
360 | }
361 | }
362 |
363 | if ($this->exactSize !== null && $this->uncompressedSize !== $this->exactSize) {
364 | throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
365 | }
366 |
367 | $this->crc = hexdec(hash_final($hash));
368 | }
369 |
370 | private function compressionInit(): ?DeflateContext
371 | {
372 | switch ($this->compressionMethod) {
373 | case CompressionMethod::STORE:
374 | // Noting to do
375 | return null;
376 | case CompressionMethod::DEFLATE:
377 | $deflateContext = deflate_init(
378 | ZLIB_ENCODING_RAW,
379 | ['level' => $this->deflateLevel]
380 | );
381 |
382 | if (!$deflateContext) {
383 | // @codeCoverageIgnoreStart
384 | throw new RuntimeException("Can't initialize deflate context.");
385 | // @codeCoverageIgnoreEnd
386 | }
387 |
388 | // False positive, resource is no longer returned from this function
389 | return $deflateContext;
390 | default:
391 | // @codeCoverageIgnoreStart
392 | throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true));
393 | // @codeCoverageIgnoreEnd
394 | }
395 | }
396 |
397 | private function getCdrFile(): string
398 | {
399 | $footer = $this->buildZip64ExtraBlock();
400 |
401 | return CentralDirectoryFileHeader::generate(
402 | versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY,
403 | versionNeededToExtract: $this->version->value,
404 | generalPurposeBitFlag: $this->generalPurposeBitFlag,
405 | compressionMethod: $this->compressionMethod,
406 | lastModificationDateTime: $this->lastModificationDateTime,
407 | crc32: $this->crc,
408 | compressedSize: $this->compressedSize > 0xFFFFFFFF
409 | ? 0xFFFFFFFF
410 | : $this->compressedSize,
411 | uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF
412 | ? 0xFFFFFFFF
413 | : $this->uncompressedSize,
414 | fileName: $this->fileName,
415 | extraField: $footer,
416 | fileComment: $this->comment,
417 | diskNumberStart: 0,
418 | internalFileAttributes: 0,
419 | externalFileAttributes: 32,
420 | relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF
421 | ? 0xFFFFFFFF
422 | : $this->startOffset,
423 | );
424 | }
425 |
426 | private function isSimulation(): bool
427 | {
428 | return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
429 | }
430 | }
431 |
--------------------------------------------------------------------------------
/src/GeneralPurposeBitFlag.php:
--------------------------------------------------------------------------------
1 | value),
32 | new PackField(format: 'V', value: Time::dateTimeToDosTime($lastModificationDateTime)),
33 | new PackField(format: 'V', value: $crc32UncompressedData),
34 | new PackField(format: 'V', value: $compressedSize),
35 | new PackField(format: 'V', value: $uncompressedSize),
36 | new PackField(format: 'v', value: strlen($fileName)),
37 | new PackField(format: 'v', value: strlen($extraField)),
38 | ) . $fileName . $extraField;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/OperationMode.php:
--------------------------------------------------------------------------------
1 | format;
32 | }, '');
33 |
34 | $args = array_map(function (self $field) {
35 | switch ($field->format) {
36 | case 'V':
37 | if ($field->value > self::MAX_V) {
38 | throw new RuntimeException(print_r($field->value, true) . ' is larger than 32 bits');
39 | }
40 | break;
41 | case 'v':
42 | if ($field->value > self::MAX_v) {
43 | throw new RuntimeException(print_r($field->value, true) . ' is larger than 16 bits');
44 | }
45 | break;
46 | case 'P': break;
47 | default:
48 | break;
49 | }
50 |
51 | return $field->value;
52 | }, $fields);
53 |
54 | return pack($fmt, ...$args);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Time.php:
--------------------------------------------------------------------------------
1 | getTimestamp() < $dosMinimumDate->getTimestamp()) {
24 | throw new DosTimeOverflowException(dateTime: $dateTime);
25 | }
26 |
27 | $dateTime = DateTimeImmutable::createFromInterface($dateTime)->sub(new DateInterval('P1980Y'));
28 |
29 | [$year, $month, $day, $hour, $minute, $second] = explode(' ', $dateTime->format('Y n j G i s'));
30 |
31 | return
32 | ((int) $year << 25) |
33 | ((int) $month << 21) |
34 | ((int) $day << 16) |
35 | ((int) $hour << 11) |
36 | ((int) $minute << 5) |
37 | ((int) $second >> 1);
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Version.php:
--------------------------------------------------------------------------------
1 | addFile(fileName: 'world.txt', data: 'Hello World');
36 | *
37 | * // add second file
38 | * $zip->addFile(fileName: 'moon.txt', data: 'Hello Moon');
39 | * ```
40 | *
41 | * 3. Finish the zip stream:
42 | *
43 | * ```php
44 | * $zip->finish();
45 | * ```
46 | *
47 | * You can also add an archive comment, add comments to individual files,
48 | * and adjust the timestamp of files. See the API documentation for each
49 | * method below for additional information.
50 | *
51 | * ## Example
52 | *
53 | * ```php
54 | * // create a new zip stream object
55 | * $zip = new ZipStream(outputName: 'some_files.zip');
56 | *
57 | * // list of local files
58 | * $files = array('foo.txt', 'bar.jpg');
59 | *
60 | * // read and add each file to the archive
61 | * foreach ($files as $path)
62 | * $zip->addFileFromPath(fileName: $path, $path);
63 | *
64 | * // write archive footer to stream
65 | * $zip->finish();
66 | * ```
67 | *
68 | * @api
69 | */
70 | class ZipStream
71 | {
72 | /**
73 | * This number corresponds to the ZIP version/OS used (2 bytes)
74 | * From: https://www.iana.org/assignments/media-types/application/zip
75 | * The upper byte (leftmost one) indicates the host system (OS) for the
76 | * file. Software can use this information to determine
77 | * the line record format for text files etc. The current
78 | * mappings are:
79 | *
80 | * 0 - MS-DOS and OS/2 (F.A.T. file systems)
81 | * 1 - Amiga 2 - VAX/VMS
82 | * 3 - *nix 4 - VM/CMS
83 | * 5 - Atari ST 6 - OS/2 H.P.F.S.
84 | * 7 - Macintosh 8 - Z-System
85 | * 9 - CP/M 10 thru 255 - unused
86 | *
87 | * The lower byte (rightmost one) indicates the version number of the
88 | * software used to encode the file. The value/10
89 | * indicates the major version number, and the value
90 | * mod 10 is the minor version number.
91 | * Here we are using 6 for the OS, indicating OS/2 H.P.F.S.
92 | * to prevent file permissions issues upon extract (see #84)
93 | * 0x603 is 00000110 00000011 in binary, so 6 and 3
94 | *
95 | * @internal
96 | */
97 | public const ZIP_VERSION_MADE_BY = 0x603;
98 |
99 | private bool $ready = true;
100 |
101 | private int $offset = 0;
102 |
103 | /**
104 | * @var string[]
105 | */
106 | private array $centralDirectoryRecords = [];
107 |
108 | /**
109 | * @var resource
110 | */
111 | private $outputStream;
112 |
113 | private readonly Closure $httpHeaderCallback;
114 |
115 | /**
116 | * @var File[]
117 | */
118 | private array $recordedSimulation = [];
119 |
120 | /**
121 | * Create a new ZipStream object.
122 | *
123 | * ##### Examples
124 | *
125 | * ```php
126 | * // create a new zip file named 'foo.zip'
127 | * $zip = new ZipStream(outputName: 'foo.zip');
128 | *
129 | * // create a new zip file named 'bar.zip' with a comment
130 | * $zip = new ZipStream(
131 | * outputName: 'bar.zip',
132 | * comment: 'this is a comment for the zip file.',
133 | * );
134 | * ```
135 | *
136 | * @param OperationMode $operationMode
137 | * The mode can be used to switch between `NORMAL` and `SIMULATION_*` modes.
138 | * For details see the `OperationMode` documentation.
139 | *
140 | * Default to `NORMAL`.
141 | *
142 | * @param string $comment
143 | * Archive Level Comment
144 | *
145 | * @param StreamInterface|resource|null $outputStream
146 | * Override the output of the archive to a different target.
147 | *
148 | * By default the archive is sent to `STDOUT`.
149 | *
150 | * @param CompressionMethod $defaultCompressionMethod
151 | * How to handle file compression. Legal values are
152 | * `CompressionMethod::DEFLATE` (the default), or
153 | * `CompressionMethod::STORE`. `STORE` sends the file raw and is
154 | * significantly faster, while `DEFLATE` compresses the file and
155 | * is much, much slower.
156 | *
157 | * @param int $defaultDeflateLevel
158 | * Default deflation level. Only relevant if `compressionMethod`
159 | * is `DEFLATE`.
160 | *
161 | * See details of [`deflate_init`](https://www.php.net/manual/en/function.deflate-init.php#refsect1-function.deflate-init-parameters)
162 | *
163 | * @param bool $enableZip64
164 | * Enable Zip64 extension, supporting very large
165 | * archives (any size > 4 GB or file count > 64k)
166 | *
167 | * @param bool $defaultEnableZeroHeader
168 | * Enable streaming files with single read.
169 | *
170 | * When the zero header is set, the file is streamed into the output
171 | * and the size & checksum are added at the end of the file. This is the
172 | * fastest method and uses the least memory. Unfortunately not all
173 | * ZIP clients fully support this and can lead to clients reporting
174 | * the generated ZIP files as corrupted in combination with other
175 | * circumstances. (Zip64 enabled, using UTF8 in comments / names etc.)
176 | *
177 | * When the zero header is not set, the length & checksum need to be
178 | * defined before the file is actually added. To prevent loading all
179 | * the data into memory, the data has to be read twice. If the data
180 | * which is added is not seekable, this call will fail.
181 | *
182 | * @param bool $sendHttpHeaders
183 | * Boolean indicating whether or not to send
184 | * the HTTP headers for this file.
185 | *
186 | * @param ?Closure $httpHeaderCallback
187 | * The method called to send HTTP headers
188 | *
189 | * @param string|null $outputName
190 | * The name of the created archive.
191 | *
192 | * Only relevant if `$sendHttpHeaders = true`.
193 | *
194 | * @param string $contentDisposition
195 | * HTTP Content-Disposition
196 | *
197 | * Only relevant if `sendHttpHeaders = true`.
198 | *
199 | * @param string $contentType
200 | * HTTP Content Type
201 | *
202 | * Only relevant if `sendHttpHeaders = true`.
203 | *
204 | * @param bool $flushOutput
205 | * Enable flush after every write to output stream.
206 | *
207 | * @return self
208 | */
209 | public function __construct(
210 | private OperationMode $operationMode = OperationMode::NORMAL,
211 | private readonly string $comment = '',
212 | $outputStream = null,
213 | private readonly CompressionMethod $defaultCompressionMethod = CompressionMethod::DEFLATE,
214 | private readonly int $defaultDeflateLevel = 6,
215 | private readonly bool $enableZip64 = true,
216 | private readonly bool $defaultEnableZeroHeader = true,
217 | private bool $sendHttpHeaders = true,
218 | ?Closure $httpHeaderCallback = null,
219 | private readonly ?string $outputName = null,
220 | private readonly string $contentDisposition = 'attachment',
221 | private readonly string $contentType = 'application/x-zip',
222 | private bool $flushOutput = false,
223 | ) {
224 | $this->outputStream = self::normalizeStream($outputStream);
225 | $this->httpHeaderCallback = $httpHeaderCallback ?? header(...);
226 | }
227 |
228 | /**
229 | * Add a file to the archive.
230 | *
231 | * ##### File Options
232 | *
233 | * See {@see addFileFromPsr7Stream()}
234 | *
235 | * ##### Examples
236 | *
237 | * ```php
238 | * // add a file named 'world.txt'
239 | * $zip->addFile(fileName: 'world.txt', data: 'Hello World!');
240 | *
241 | * // add a file named 'bar.jpg' with a comment and a last-modified
242 | * // time of two hours ago
243 | * $zip->addFile(
244 | * fileName: 'bar.jpg',
245 | * data: $data,
246 | * comment: 'this is a comment about bar.jpg',
247 | * lastModificationDateTime: new DateTime('2 hours ago'),
248 | * );
249 | * ```
250 | *
251 | * @param string $data
252 | *
253 | * contents of file
254 | */
255 | public function addFile(
256 | string $fileName,
257 | string $data,
258 | string $comment = '',
259 | ?CompressionMethod $compressionMethod = null,
260 | ?int $deflateLevel = null,
261 | ?DateTimeInterface $lastModificationDateTime = null,
262 | ?int $maxSize = null,
263 | ?int $exactSize = null,
264 | ?bool $enableZeroHeader = null,
265 | ): void {
266 | $this->addFileFromCallback(
267 | fileName: $fileName,
268 | callback: fn() => $data,
269 | comment: $comment,
270 | compressionMethod: $compressionMethod,
271 | deflateLevel: $deflateLevel,
272 | lastModificationDateTime: $lastModificationDateTime,
273 | maxSize: $maxSize,
274 | exactSize: $exactSize,
275 | enableZeroHeader: $enableZeroHeader,
276 | );
277 | }
278 |
279 | /**
280 | * Add a file at path to the archive.
281 | *
282 | * ##### File Options
283 | *
284 | * See {@see addFileFromPsr7Stream()}
285 | *
286 | * ###### Examples
287 | *
288 | * ```php
289 | * // add a file named 'foo.txt' from the local file '/tmp/foo.txt'
290 | * $zip->addFileFromPath(
291 | * fileName: 'foo.txt',
292 | * path: '/tmp/foo.txt',
293 | * );
294 | *
295 | * // add a file named 'bigfile.rar' from the local file
296 | * // '/usr/share/bigfile.rar' with a comment and a last-modified
297 | * // time of two hours ago
298 | * $zip->addFileFromPath(
299 | * fileName: 'bigfile.rar',
300 | * path: '/usr/share/bigfile.rar',
301 | * comment: 'this is a comment about bigfile.rar',
302 | * lastModificationDateTime: new DateTime('2 hours ago'),
303 | * );
304 | * ```
305 | *
306 | * @throws \ZipStream\Exception\FileNotFoundException
307 | * @throws \ZipStream\Exception\FileNotReadableException
308 | */
309 | public function addFileFromPath(
310 | /**
311 | * name of file in archive (including directory path).
312 | */
313 | string $fileName,
314 |
315 | /**
316 | * path to file on disk (note: paths should be encoded using
317 | * UNIX-style forward slashes -- e.g '/path/to/some/file').
318 | */
319 | string $path,
320 | string $comment = '',
321 | ?CompressionMethod $compressionMethod = null,
322 | ?int $deflateLevel = null,
323 | ?DateTimeInterface $lastModificationDateTime = null,
324 | ?int $maxSize = null,
325 | ?int $exactSize = null,
326 | ?bool $enableZeroHeader = null,
327 | ): void {
328 | if (!is_readable($path)) {
329 | if (!file_exists($path)) {
330 | throw new FileNotFoundException($path);
331 | }
332 | throw new FileNotReadableException($path);
333 | }
334 |
335 | $fileTime = filemtime($path);
336 | if ($fileTime !== false) {
337 | $lastModificationDateTime ??= (new DateTimeImmutable())->setTimestamp($fileTime);
338 | }
339 |
340 | $this->addFileFromCallback(
341 | fileName: $fileName,
342 | callback: function () use ($path) {
343 |
344 | $stream = fopen($path, 'rb');
345 |
346 | if (!$stream) {
347 | // @codeCoverageIgnoreStart
348 | throw new ResourceActionException('fopen');
349 | // @codeCoverageIgnoreEnd
350 | }
351 |
352 | return $stream;
353 | },
354 | comment: $comment,
355 | compressionMethod: $compressionMethod,
356 | deflateLevel: $deflateLevel,
357 | lastModificationDateTime: $lastModificationDateTime,
358 | maxSize: $maxSize,
359 | exactSize: $exactSize,
360 | enableZeroHeader: $enableZeroHeader,
361 | );
362 | }
363 |
364 | /**
365 | * Add an open stream (resource) to the archive.
366 | *
367 | * ##### File Options
368 | *
369 | * See {@see addFileFromPsr7Stream()}
370 | *
371 | * ##### Examples
372 | *
373 | * ```php
374 | * // create a temporary file stream and write text to it
375 | * $filePointer = tmpfile();
376 | * fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');
377 | *
378 | * // add a file named 'streamfile.txt' from the content of the stream
379 | * $archive->addFileFromStream(
380 | * fileName: 'streamfile.txt',
381 | * stream: $filePointer,
382 | * );
383 | * ```
384 | *
385 | * @param resource $stream contents of file as a stream resource
386 | */
387 | public function addFileFromStream(
388 | string $fileName,
389 | $stream,
390 | string $comment = '',
391 | ?CompressionMethod $compressionMethod = null,
392 | ?int $deflateLevel = null,
393 | ?DateTimeInterface $lastModificationDateTime = null,
394 | ?int $maxSize = null,
395 | ?int $exactSize = null,
396 | ?bool $enableZeroHeader = null,
397 | ): void {
398 | $this->addFileFromCallback(
399 | fileName: $fileName,
400 | callback: fn() => $stream,
401 | comment: $comment,
402 | compressionMethod: $compressionMethod,
403 | deflateLevel: $deflateLevel,
404 | lastModificationDateTime: $lastModificationDateTime,
405 | maxSize: $maxSize,
406 | exactSize: $exactSize,
407 | enableZeroHeader: $enableZeroHeader,
408 | );
409 | }
410 |
411 | /**
412 | * Add an open stream to the archive.
413 | *
414 | * ##### Examples
415 | *
416 | * ```php
417 | * $stream = $response->getBody();
418 | * // add a file named 'streamfile.txt' from the content of the stream
419 | * $archive->addFileFromPsr7Stream(
420 | * fileName: 'streamfile.txt',
421 | * stream: $stream,
422 | * );
423 | * ```
424 | *
425 | * @param string $fileName
426 | * path of file in archive (including directory)
427 | *
428 | * @param StreamInterface $stream
429 | * contents of file as a stream resource
430 | *
431 | * @param string $comment
432 | * ZIP comment for this file
433 | *
434 | * @param ?CompressionMethod $compressionMethod
435 | * Override `defaultCompressionMethod`
436 | *
437 | * See {@see __construct()}
438 | *
439 | * @param ?int $deflateLevel
440 | * Override `defaultDeflateLevel`
441 | *
442 | * See {@see __construct()}
443 | *
444 | * @param ?DateTimeInterface $lastModificationDateTime
445 | * Set last modification time of file.
446 | *
447 | * Default: `now`
448 | *
449 | * @param ?int $maxSize
450 | * Only read `maxSize` bytes from file.
451 | *
452 | * The file is considered done when either reaching `EOF`
453 | * or the `maxSize`.
454 | *
455 | * @param ?int $exactSize
456 | * Read exactly `exactSize` bytes from file.
457 | * If `EOF` is reached before reading `exactSize` bytes, an error will be
458 | * thrown. The parameter allows for faster size calculations if the `stream`
459 | * does not support `fstat` size or is slow and otherwise known beforehand.
460 | *
461 | * @param ?bool $enableZeroHeader
462 | * Override `defaultEnableZeroHeader`
463 | *
464 | * See {@see __construct()}
465 | */
466 | public function addFileFromPsr7Stream(
467 | string $fileName,
468 | StreamInterface $stream,
469 | string $comment = '',
470 | ?CompressionMethod $compressionMethod = null,
471 | ?int $deflateLevel = null,
472 | ?DateTimeInterface $lastModificationDateTime = null,
473 | ?int $maxSize = null,
474 | ?int $exactSize = null,
475 | ?bool $enableZeroHeader = null,
476 | ): void {
477 | $this->addFileFromCallback(
478 | fileName: $fileName,
479 | callback: fn() => $stream,
480 | comment: $comment,
481 | compressionMethod: $compressionMethod,
482 | deflateLevel: $deflateLevel,
483 | lastModificationDateTime: $lastModificationDateTime,
484 | maxSize: $maxSize,
485 | exactSize: $exactSize,
486 | enableZeroHeader: $enableZeroHeader,
487 | );
488 | }
489 |
490 | /**
491 | * Add a file based on a callback.
492 | *
493 | * This is useful when you want to simulate a lot of files without keeping
494 | * all of the file handles open at the same time.
495 | *
496 | * ##### Examples
497 | *
498 | * ```php
499 | * foreach($files as $name => $size) {
500 | * $archive->addFileFromCallback(
501 | * fileName: 'streamfile.txt',
502 | * exactSize: $size,
503 | * callback: function() use($name): Psr\Http\Message\StreamInterface {
504 | * $response = download($name);
505 | * return $response->getBody();
506 | * }
507 | * );
508 | * }
509 | * ```
510 | *
511 | * @param string $fileName
512 | * path of file in archive (including directory)
513 | *
514 | * @param Closure $callback
515 | * @psalm-param Closure(): (resource|StreamInterface|string) $callback
516 | * A callback to get the file contents in the shape of a PHP stream,
517 | * a Psr StreamInterface implementation, or a string.
518 | *
519 | * @param string $comment
520 | * ZIP comment for this file
521 | *
522 | * @param ?CompressionMethod $compressionMethod
523 | * Override `defaultCompressionMethod`
524 | *
525 | * See {@see __construct()}
526 | *
527 | * @param ?int $deflateLevel
528 | * Override `defaultDeflateLevel`
529 | *
530 | * See {@see __construct()}
531 | *
532 | * @param ?DateTimeInterface $lastModificationDateTime
533 | * Set last modification time of file.
534 | *
535 | * Default: `now`
536 | *
537 | * @param ?int $maxSize
538 | * Only read `maxSize` bytes from file.
539 | *
540 | * The file is considered done when either reaching `EOF`
541 | * or the `maxSize`.
542 | *
543 | * @param ?int $exactSize
544 | * Read exactly `exactSize` bytes from file.
545 | * If `EOF` is reached before reading `exactSize` bytes, an error will be
546 | * thrown. The parameter allows for faster size calculations if the `stream`
547 | * does not support `fstat` size or is slow and otherwise known beforehand.
548 | *
549 | * @param ?bool $enableZeroHeader
550 | * Override `defaultEnableZeroHeader`
551 | *
552 | * See {@see __construct()}
553 | */
554 | public function addFileFromCallback(
555 | string $fileName,
556 | Closure $callback,
557 | string $comment = '',
558 | ?CompressionMethod $compressionMethod = null,
559 | ?int $deflateLevel = null,
560 | ?DateTimeInterface $lastModificationDateTime = null,
561 | ?int $maxSize = null,
562 | ?int $exactSize = null,
563 | ?bool $enableZeroHeader = null,
564 | ): void {
565 | $file = new File(
566 | dataCallback: function () use ($callback, $maxSize) {
567 | $data = $callback();
568 |
569 | if (is_resource($data)) {
570 | return $data;
571 | }
572 |
573 | if ($data instanceof StreamInterface) {
574 | return StreamWrapper::getResource($data);
575 | }
576 |
577 |
578 | $stream = fopen('php://memory', 'rw+');
579 | if ($stream === false) {
580 | // @codeCoverageIgnoreStart
581 | throw new ResourceActionException('fopen');
582 | // @codeCoverageIgnoreEnd
583 | }
584 | if ($maxSize !== null && fwrite($stream, $data, $maxSize) === false) {
585 | // @codeCoverageIgnoreStart
586 | throw new ResourceActionException('fwrite', $stream);
587 | // @codeCoverageIgnoreEnd
588 | } elseif (fwrite($stream, $data) === false) {
589 | // @codeCoverageIgnoreStart
590 | throw new ResourceActionException('fwrite', $stream);
591 | // @codeCoverageIgnoreEnd
592 | }
593 | if (rewind($stream) === false) {
594 | // @codeCoverageIgnoreStart
595 | throw new ResourceActionException('rewind', $stream);
596 | // @codeCoverageIgnoreEnd
597 | }
598 |
599 | return $stream;
600 |
601 | },
602 | send: $this->send(...),
603 | recordSentBytes: $this->recordSentBytes(...),
604 | operationMode: $this->operationMode,
605 | fileName: $fileName,
606 | startOffset: $this->offset,
607 | compressionMethod: $compressionMethod ?? $this->defaultCompressionMethod,
608 | comment: $comment,
609 | deflateLevel: $deflateLevel ?? $this->defaultDeflateLevel,
610 | lastModificationDateTime: $lastModificationDateTime ?? new DateTimeImmutable(),
611 | maxSize: $maxSize,
612 | exactSize: $exactSize,
613 | enableZip64: $this->enableZip64,
614 | enableZeroHeader: $enableZeroHeader ?? $this->defaultEnableZeroHeader,
615 | );
616 |
617 | if ($this->operationMode !== OperationMode::NORMAL) {
618 | $this->recordedSimulation[] = $file;
619 | }
620 |
621 | $this->centralDirectoryRecords[] = $file->process();
622 | }
623 |
624 | /**
625 | * Add a directory to the archive.
626 | *
627 | * ##### File Options
628 | *
629 | * See {@see addFileFromPsr7Stream()}
630 | *
631 | * ##### Examples
632 | *
633 | * ```php
634 | * // add a directory named 'world/'
635 | * $zip->addDirectory(fileName: 'world/');
636 | * ```
637 | */
638 | public function addDirectory(
639 | string $fileName,
640 | string $comment = '',
641 | ?DateTimeInterface $lastModificationDateTime = null,
642 | ): void {
643 | if (!str_ends_with($fileName, '/')) {
644 | $fileName .= '/';
645 | }
646 |
647 | $this->addFile(
648 | fileName: $fileName,
649 | data: '',
650 | comment: $comment,
651 | compressionMethod: CompressionMethod::STORE,
652 | deflateLevel: null,
653 | lastModificationDateTime: $lastModificationDateTime,
654 | maxSize: 0,
655 | exactSize: 0,
656 | enableZeroHeader: false,
657 | );
658 | }
659 |
660 | /**
661 | * Executes a previously calculated simulation.
662 | *
663 | * ##### Example
664 | *
665 | * ```php
666 | * $zip = new ZipStream(
667 | * outputName: 'foo.zip',
668 | * operationMode: OperationMode::SIMULATE_STRICT,
669 | * );
670 | *
671 | * $zip->addFile('test.txt', 'Hello World');
672 | *
673 | * $size = $zip->finish();
674 | *
675 | * header('Content-Length: '. $size);
676 | *
677 | * $zip->executeSimulation();
678 | * ```
679 | */
680 | public function executeSimulation(): void
681 | {
682 | if ($this->operationMode !== OperationMode::NORMAL) {
683 | throw new RuntimeException('Zip simulation is not finished.');
684 | }
685 |
686 | foreach ($this->recordedSimulation as $file) {
687 | $this->centralDirectoryRecords[] = $file->cloneSimulationExecution()->process();
688 | }
689 |
690 | $this->finish();
691 | }
692 |
693 | /**
694 | * Write zip footer to stream.
695 | *
696 | * The class is left in an unusable state after `finish`.
697 | *
698 | * ##### Example
699 | *
700 | * ```php
701 | * // write footer to stream
702 | * $zip->finish();
703 | * ```
704 | */
705 | public function finish(): int
706 | {
707 | $centralDirectoryStartOffsetOnDisk = $this->offset;
708 | $sizeOfCentralDirectory = 0;
709 |
710 | // add trailing cdr file records
711 | foreach ($this->centralDirectoryRecords as $centralDirectoryRecord) {
712 | $this->send($centralDirectoryRecord);
713 | $sizeOfCentralDirectory += strlen($centralDirectoryRecord);
714 | }
715 |
716 | // Add 64bit headers (if applicable)
717 | if (count($this->centralDirectoryRecords) >= 0xFFFF ||
718 | $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
719 | $sizeOfCentralDirectory > 0xFFFFFFFF) {
720 | if (!$this->enableZip64) {
721 | throw new OverflowException();
722 | }
723 |
724 | $this->send(Zip64\EndOfCentralDirectory::generate(
725 | versionMadeBy: self::ZIP_VERSION_MADE_BY,
726 | versionNeededToExtract: Version::ZIP64->value,
727 | numberOfThisDisk: 0,
728 | numberOfTheDiskWithCentralDirectoryStart: 0,
729 | numberOfCentralDirectoryEntriesOnThisDisk: count($this->centralDirectoryRecords),
730 | numberOfCentralDirectoryEntries: count($this->centralDirectoryRecords),
731 | sizeOfCentralDirectory: $sizeOfCentralDirectory,
732 | centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk,
733 | extensibleDataSector: '',
734 | ));
735 |
736 | $this->send(Zip64\EndOfCentralDirectoryLocator::generate(
737 | numberOfTheDiskWithZip64CentralDirectoryStart: 0x00,
738 | zip64centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk + $sizeOfCentralDirectory,
739 | totalNumberOfDisks: 1,
740 | ));
741 | }
742 |
743 | // add trailing cdr eof record
744 | $numberOfCentralDirectoryEntries = min(count($this->centralDirectoryRecords), 0xFFFF);
745 | $this->send(EndOfCentralDirectory::generate(
746 | numberOfThisDisk: 0x00,
747 | numberOfTheDiskWithCentralDirectoryStart: 0x00,
748 | numberOfCentralDirectoryEntriesOnThisDisk: $numberOfCentralDirectoryEntries,
749 | numberOfCentralDirectoryEntries: $numberOfCentralDirectoryEntries,
750 | sizeOfCentralDirectory: min($sizeOfCentralDirectory, 0xFFFFFFFF),
751 | centralDirectoryStartOffsetOnDisk: min($centralDirectoryStartOffsetOnDisk, 0xFFFFFFFF),
752 | zipFileComment: $this->comment,
753 | ));
754 |
755 | $size = $this->offset;
756 |
757 | // The End
758 | $this->clear();
759 |
760 | return $size;
761 | }
762 |
763 | /**
764 | * @param StreamInterface|resource|null $outputStream
765 | * @return resource
766 | */
767 | private static function normalizeStream($outputStream)
768 | {
769 | if ($outputStream instanceof StreamInterface) {
770 | return StreamWrapper::getResource($outputStream);
771 | }
772 | if (is_resource($outputStream)) {
773 | return $outputStream;
774 | }
775 | $resource = fopen('php://output', 'wb');
776 |
777 | if ($resource === false) {
778 | throw new RuntimeException('fopen of php://output failed');
779 | }
780 |
781 | return $resource;
782 | }
783 |
784 | /**
785 | * Record sent bytes
786 | */
787 | private function recordSentBytes(int $sentBytes): void
788 | {
789 | $this->offset += $sentBytes;
790 | }
791 |
792 | /**
793 | * Send string, sending HTTP headers if necessary.
794 | * Flush output after write if configure option is set.
795 | */
796 | private function send(string $data): void
797 | {
798 | if (!$this->ready) {
799 | throw new RuntimeException('Archive is already finished');
800 | }
801 |
802 | if ($this->operationMode === OperationMode::NORMAL && $this->sendHttpHeaders) {
803 | $this->sendHttpHeaders();
804 | $this->sendHttpHeaders = false;
805 | }
806 |
807 | $this->recordSentBytes(strlen($data));
808 |
809 | if ($this->operationMode === OperationMode::NORMAL) {
810 | if (fwrite($this->outputStream, $data) === false) {
811 | throw new ResourceActionException('fwrite', $this->outputStream);
812 | }
813 |
814 | if ($this->flushOutput) {
815 | // flush output buffer if it is on and flushable
816 | $status = ob_get_status();
817 | if (isset($status['flags']) && is_int($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
818 | ob_flush();
819 | }
820 |
821 | // Flush system buffers after flushing userspace output buffer
822 | flush();
823 | }
824 | }
825 | }
826 |
827 | /**
828 | * Send HTTP headers for this stream.
829 | */
830 | private function sendHttpHeaders(): void
831 | {
832 | // grab content disposition
833 | $disposition = $this->contentDisposition;
834 |
835 | if ($this->outputName !== null) {
836 | // Various different browsers dislike various characters here. Strip them all for safety.
837 | $safeOutput = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->outputName));
838 |
839 | // Check if we need to UTF-8 encode the filename
840 | $urlencoded = rawurlencode($safeOutput);
841 | $disposition .= "; filename*=UTF-8''{$urlencoded}";
842 | }
843 |
844 | $headers = [
845 | 'Content-Type' => $this->contentType,
846 | 'Content-Disposition' => $disposition,
847 | 'Pragma' => 'public',
848 | 'Cache-Control' => 'public, must-revalidate',
849 | 'Content-Transfer-Encoding' => 'binary',
850 | ];
851 |
852 | foreach ($headers as $key => $val) {
853 | ($this->httpHeaderCallback)("$key: $val");
854 | }
855 | }
856 |
857 | /**
858 | * Clear all internal variables. Note that the stream object is not
859 | * usable after this.
860 | */
861 | private function clear(): void
862 | {
863 | $this->centralDirectoryRecords = [];
864 | $this->offset = 0;
865 |
866 | if ($this->operationMode === OperationMode::NORMAL) {
867 | $this->ready = false;
868 | $this->recordedSimulation = [];
869 | } else {
870 | $this->operationMode = OperationMode::NORMAL;
871 | }
872 | }
873 | }
874 |
--------------------------------------------------------------------------------
/src/Zs/ExtendedInformationExtraField.php:
--------------------------------------------------------------------------------
1 | fail("File {$filePath} must contain {$needle}");
28 | }
29 |
30 | protected function assertFileDoesNotContain(string $filePath, string $needle): void
31 | {
32 | $last = '';
33 |
34 | $handle = fopen($filePath, 'r');
35 | while (!feof($handle)) {
36 | $line = fgets($handle, 1024);
37 |
38 | if (str_contains($last . $line, $needle)) {
39 | fclose($handle);
40 |
41 | $this->fail("File {$filePath} must not contain {$needle}");
42 | }
43 |
44 | $last = $line;
45 | }
46 |
47 | fclose($handle);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/test/CentralDirectoryFileHeaderTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
37 | bin2hex($header),
38 | '504b0102' . // 4 bytes; central file header signature
39 | '0306' . // 2 bytes; version made by
40 | '2d00' . // 2 bytes; version needed to extract
41 | '2222' . // 2 bytes; general purpose bit flag
42 | '0800' . // 2 bytes; compression method
43 | '2008' . // 2 bytes; last mod file time
44 | '2154' . // 2 bytes; last mod file date
45 | '11111111' . // 4 bytes; crc-32
46 | '77777777' . // 4 bytes; compressed size
47 | '99999999' . // 4 bytes; uncompressed size
48 | '0800' . // 2 bytes; file name length (n)
49 | '0c00' . // 2 bytes; extra field length (m)
50 | '0c00' . // 2 bytes; file comment length (o)
51 | '0000' . // 2 bytes; disk number start
52 | '0000' . // 2 bytes; internal file attributes
53 | '20000000' . // 4 bytes; external file attributes
54 | '34120000' . // 4 bytes; relative offset of local header
55 | '746573742e706e67' . // n bytes; file name
56 | '736f6d6520636f6e74656e74' . // m bytes; extra field
57 | '736f6d6520636f6d6d656e74' // o bytes; file comment
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/test/DataDescriptorTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
15 | bin2hex(DataDescriptor::generate(
16 | crc32UncompressedData: 0x11111111,
17 | compressedSize: 0x77777777,
18 | uncompressedSize: 0x99999999,
19 | )),
20 | '504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
21 | '11111111' . // 4 bytes; CRC-32 of uncompressed data
22 | '77777777' . // 4 bytes; Compressed size
23 | '99999999' // 4 bytes; Uncompressed size
24 | );
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/test/EndOfCentralDirectoryTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
15 | bin2hex(EndOfCentralDirectory::generate(
16 | numberOfThisDisk: 0x00,
17 | numberOfTheDiskWithCentralDirectoryStart: 0x00,
18 | numberOfCentralDirectoryEntriesOnThisDisk: 0x10,
19 | numberOfCentralDirectoryEntries: 0x10,
20 | sizeOfCentralDirectory: 0x22,
21 | centralDirectoryStartOffsetOnDisk: 0x33,
22 | zipFileComment: 'foo',
23 | )),
24 | '504b0506' . // 4 bytes; end of central dir signature 0x06054b50
25 | '0000' . // 2 bytes; number of this disk
26 | '0000' . // 2 bytes; number of the disk with the start of the central directory
27 | '1000' . // 2 bytes; total number of entries in the central directory on this disk
28 | '1000' . // 2 bytes; total number of entries in the central directory
29 | '22000000' . // 4 bytes; size of the central directory
30 | '33000000' . // 4 bytes; offset of start of central directory with respect to the starting disk number
31 | '0300' . // 2 bytes; .ZIP file comment length
32 | bin2hex('foo')
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/test/EndlessCycleStream.php:
--------------------------------------------------------------------------------
1 | detach();
24 | }
25 |
26 | /**
27 | * @return null
28 | */
29 | public function detach()
30 | {
31 | return;
32 | }
33 |
34 | public function getSize(): ?int
35 | {
36 | return null;
37 | }
38 |
39 | public function tell(): int
40 | {
41 | return $this->offset;
42 | }
43 |
44 | public function eof(): bool
45 | {
46 | return false;
47 | }
48 |
49 | public function isSeekable(): bool
50 | {
51 | return true;
52 | }
53 |
54 | public function seek(int $offset, int $whence = SEEK_SET): void
55 | {
56 | switch ($whence) {
57 | case SEEK_SET:
58 | $this->offset = $offset;
59 | break;
60 | case SEEK_CUR:
61 | $this->offset += $offset;
62 | break;
63 | case SEEK_END:
64 | throw new RuntimeException('Infinite Stream!');
65 | break;
66 | }
67 | }
68 |
69 | public function rewind(): void
70 | {
71 | $this->seek(0);
72 | }
73 |
74 | public function isWritable(): bool
75 | {
76 | return false;
77 | }
78 |
79 | public function write(string $string): int
80 | {
81 | throw new RuntimeException('Not writeable');
82 | }
83 |
84 | public function isReadable(): bool
85 | {
86 | return true;
87 | }
88 |
89 | public function read(int $length): string
90 | {
91 | $this->offset += $length;
92 | return substr(str_repeat($this->toRepeat, (int) ceil($length / strlen($this->toRepeat))), 0, $length);
93 | }
94 |
95 | public function getContents(): string
96 | {
97 | throw new RuntimeException('Infinite Stream!');
98 | }
99 |
100 | public function getMetadata(?string $key = null): array|null
101 | {
102 | return $key !== null ? null : [];
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/test/FaultInjectionResource.php:
--------------------------------------------------------------------------------
1 | context);
31 |
32 | if (!isset($options[self::NAME]['injectFaults'])) {
33 | return false;
34 | }
35 |
36 | $this->mode = $mode;
37 | $this->injectFaults = $options[self::NAME]['injectFaults'];
38 |
39 | if ($this->shouldFail(__FUNCTION__)) {
40 | return false;
41 | }
42 |
43 | return true;
44 | }
45 |
46 | public function stream_write(string $data)
47 | {
48 | if ($this->shouldFail(__FUNCTION__)) {
49 | return false;
50 | }
51 | return true;
52 | }
53 |
54 | public function stream_eof()
55 | {
56 | return true;
57 | }
58 |
59 | public function stream_seek(int $offset, int $whence): bool
60 | {
61 | if ($this->shouldFail(__FUNCTION__)) {
62 | return false;
63 | }
64 |
65 | return true;
66 | }
67 |
68 | public function stream_tell(): int
69 | {
70 | if ($this->shouldFail(__FUNCTION__)) {
71 | return false;
72 | }
73 |
74 | return 0;
75 | }
76 |
77 | public static function register(): void
78 | {
79 | if (!in_array(self::NAME, stream_get_wrappers(), true)) {
80 | stream_wrapper_register(self::NAME, __CLASS__);
81 | }
82 | }
83 |
84 | public function stream_stat(): array
85 | {
86 | static $modeMap = [
87 | 'r' => 33060,
88 | 'rb' => 33060,
89 | 'r+' => 33206,
90 | 'w' => 33188,
91 | 'wb' => 33188,
92 | ];
93 |
94 | return [
95 | 'dev' => 0,
96 | 'ino' => 0,
97 | 'mode' => $modeMap[$this->mode],
98 | 'nlink' => 0,
99 | 'uid' => 0,
100 | 'gid' => 0,
101 | 'rdev' => 0,
102 | 'size' => 0,
103 | 'atime' => 0,
104 | 'mtime' => 0,
105 | 'ctime' => 0,
106 | 'blksize' => 0,
107 | 'blocks' => 0,
108 | ];
109 | }
110 |
111 | public function url_stat(string $path, int $flags): array
112 | {
113 | return [
114 | 'dev' => 0,
115 | 'ino' => 0,
116 | 'mode' => 0,
117 | 'nlink' => 0,
118 | 'uid' => 0,
119 | 'gid' => 0,
120 | 'rdev' => 0,
121 | 'size' => 0,
122 | 'atime' => 0,
123 | 'mtime' => 0,
124 | 'ctime' => 0,
125 | 'blksize' => 0,
126 | 'blocks' => 0,
127 | ];
128 | }
129 |
130 | private static function createStreamContext(array $injectFaults)
131 | {
132 | return stream_context_create([
133 | self::NAME => ['injectFaults' => $injectFaults],
134 | ]);
135 | }
136 |
137 | private function shouldFail(string $function): bool
138 | {
139 | return in_array($function, $this->injectFaults, true);
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/test/LocalFileHeaderTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
31 | bin2hex((string) $header),
32 | '504b0304' . // 4 bytes; Local file header signature
33 | '2d00' . // 2 bytes; Version needed to extract (minimum)
34 | '2222' . // 2 bytes; General purpose bit flag
35 | '0800' . // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
36 | '2008' . // 2 bytes; File last modification time
37 | '2154' . // 2 bytes; File last modification date
38 | '11111111' . // 4 bytes; CRC-32 of uncompressed data
39 | '77777777' . // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
40 | '99999999' . // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
41 | '0800' . // 2 bytes; File name length (n)
42 | '0c00' . // 2 bytes; Extra field length (m)
43 | '746573742e706e67' . // n bytes; File name
44 | '736f6d6520636f6e74656e74' // m bytes; Extra field
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/test/PackFieldTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
16 | bin2hex(PackField::pack(new PackField(format: 'v', value: 0x1122))),
17 | '2211',
18 | );
19 | }
20 |
21 | public function testOverflow2(): void
22 | {
23 | $this->expectException(RuntimeException::class);
24 |
25 | PackField::pack(new PackField(format: 'v', value: 0xFFFFF));
26 | }
27 |
28 | public function testOverflow4(): void
29 | {
30 | $this->expectException(RuntimeException::class);
31 |
32 | PackField::pack(new PackField(format: 'V', value: 0xFFFFFFFFF));
33 | }
34 |
35 | public function testUnknownOperator(): void
36 | {
37 | $this->assertSame(
38 | bin2hex(PackField::pack(new PackField(format: 'a', value: 0x1122))),
39 | '34',
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/test/ResourceStream.php:
--------------------------------------------------------------------------------
1 | isSeekable()) {
25 | $this->seek(0);
26 | }
27 | return (string) stream_get_contents($this->stream);
28 | }
29 |
30 | public function close(): void
31 | {
32 | $stream = $this->detach();
33 | if ($stream) {
34 | fclose($stream);
35 | }
36 | }
37 |
38 | public function detach()
39 | {
40 | $result = $this->stream;
41 | // According to the interface, the stream is left in an unusable state;
42 | /** @psalm-suppress PossiblyNullPropertyAssignmentValue */
43 | $this->stream = null;
44 | return $result;
45 | }
46 |
47 | public function seek(int $offset, int $whence = SEEK_SET): void
48 | {
49 | if (!$this->isSeekable()) {
50 | throw new RuntimeException();
51 | }
52 | if (fseek($this->stream, $offset, $whence) !== 0) {
53 | // @codeCoverageIgnoreStart
54 | throw new RuntimeException();
55 | // @codeCoverageIgnoreEnd
56 | }
57 | }
58 |
59 | public function isSeekable(): bool
60 | {
61 | return (bool) $this->getMetadata('seekable');
62 | }
63 |
64 | public function getMetadata(?string $key = null)
65 | {
66 | $metadata = stream_get_meta_data($this->stream);
67 | return $key !== null ? @$metadata[$key] : $metadata;
68 | }
69 |
70 | public function getSize(): ?int
71 | {
72 | $stats = fstat($this->stream);
73 | return $stats['size'];
74 | }
75 |
76 | public function tell(): int
77 | {
78 | $position = ftell($this->stream);
79 | if ($position === false) {
80 | // @codeCoverageIgnoreStart
81 | throw new RuntimeException();
82 | // @codeCoverageIgnoreEnd
83 | }
84 | return $position;
85 | }
86 |
87 | public function eof(): bool
88 | {
89 | return feof($this->stream);
90 | }
91 |
92 | public function rewind(): void
93 | {
94 | $this->seek(0);
95 | }
96 |
97 | public function write(string $string): int
98 | {
99 | if (!$this->isWritable()) {
100 | throw new RuntimeException();
101 | }
102 | if (fwrite($this->stream, $string) === false) {
103 | // @codeCoverageIgnoreStart
104 | throw new RuntimeException();
105 | // @codeCoverageIgnoreEnd
106 | }
107 | return strlen($string);
108 | }
109 |
110 | public function isWritable(): bool
111 | {
112 | $mode = $this->getMetadata('mode');
113 | if (!is_string($mode)) {
114 | // @codeCoverageIgnoreStart
115 | throw new RuntimeException('Could not get stream mode from metadata!');
116 | // @codeCoverageIgnoreEnd
117 | }
118 | return preg_match('/[waxc+]/', $mode) === 1;
119 | }
120 |
121 | public function read(int $length): string
122 | {
123 | if (!$this->isReadable()) {
124 | throw new RuntimeException();
125 | }
126 | $result = fread($this->stream, $length);
127 | if ($result === false) {
128 | // @codeCoverageIgnoreStart
129 | throw new RuntimeException();
130 | // @codeCoverageIgnoreEnd
131 | }
132 | return $result;
133 | }
134 |
135 | public function isReadable(): bool
136 | {
137 | $mode = $this->getMetadata('mode');
138 | if (!is_string($mode)) {
139 | // @codeCoverageIgnoreStart
140 | throw new RuntimeException('Could not get stream mode from metadata!');
141 | // @codeCoverageIgnoreEnd
142 | }
143 | return preg_match('/[r+]/', $mode) === 1;
144 | }
145 |
146 | public function getContents(): string
147 | {
148 | if (!$this->isReadable()) {
149 | throw new RuntimeException();
150 | }
151 | $result = stream_get_contents($this->stream);
152 | if ($result === false) {
153 | // @codeCoverageIgnoreStart
154 | throw new RuntimeException();
155 | // @codeCoverageIgnoreEnd
156 | }
157 | return $result;
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/test/Tempfile.php:
--------------------------------------------------------------------------------
1 | getTmpFileStream();
19 |
20 | $this->tempfile = $tempfile;
21 | $this->tempfileStream = $tempfileStream;
22 | }
23 |
24 | protected function tearDown(): void
25 | {
26 | unlink($this->tempfile);
27 | if (is_resource($this->tempfileStream)) {
28 | fclose($this->tempfileStream);
29 | }
30 |
31 | $this->tempfile = null;
32 | $this->tempfileStream = null;
33 | }
34 |
35 | protected function getTmpFileStream(): array
36 | {
37 | $tmp = tempnam(sys_get_temp_dir(), 'zipstreamtest');
38 | $stream = fopen($tmp, 'wb+');
39 |
40 | return [$tmp, $stream];
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/test/TimeTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
17 | Time::dateTimeToDosTime(new DateTimeImmutable('2014-11-17T17:46:08Z')),
18 | 1165069764
19 | );
20 |
21 | // January 1 1980 - DOS Epoch.
22 | $this->assertSame(
23 | Time::dateTimeToDosTime(new DateTimeImmutable('1980-01-01T00:00:00+00:00')),
24 | 2162688
25 | );
26 |
27 | // Local timezone different than UTC.
28 | $prevLocalTimezone = date_default_timezone_get();
29 | date_default_timezone_set('Europe/Berlin');
30 | $this->assertSame(
31 | Time::dateTimeToDosTime(new DateTimeImmutable('1980-01-01T00:00:00+00:00')),
32 | 2162688
33 | );
34 | date_default_timezone_set($prevLocalTimezone);
35 | }
36 |
37 | public function testTooEarlyDateToDosTime(): void
38 | {
39 | $this->expectException(DosTimeOverflowException::class);
40 |
41 | // January 1 1980 is the minimum DOS Epoch.
42 | Time::dateTimeToDosTime(new DateTimeImmutable('1970-01-01T00:00:00+00:00'));
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/test/Util.php:
--------------------------------------------------------------------------------
1 | cmdExists('hexdump')) {
41 | return '';
42 | }
43 |
44 | $output = [];
45 |
46 | if (!exec("hexdump -C \"$path\" | head -n 50", $output)) {
47 | return '';
48 | }
49 |
50 | return "\nHexdump:\n" . implode("\n", $output);
51 | }
52 |
53 | protected function validateAndExtractZip(string $zipPath): string
54 | {
55 | $tmpDir = $this->getTmpDir();
56 |
57 | $zipArchive = new ZipArchive();
58 | $result = $zipArchive->open($zipPath);
59 |
60 | if ($result !== true) {
61 | $codeName = $this->zipArchiveOpenErrorCodeName($result);
62 | $debugInformation = $this->dumpZipContents($zipPath);
63 |
64 | $this->fail("Failed to open {$zipPath}. Code: $result ($codeName)$debugInformation");
65 |
66 | return $tmpDir;
67 | }
68 |
69 | $this->assertSame(0, $zipArchive->status);
70 | $this->assertSame(0, $zipArchive->statusSys);
71 |
72 | $zipArchive->extractTo($tmpDir);
73 | $zipArchive->close();
74 |
75 | return $tmpDir;
76 | }
77 |
78 | protected function zipArchiveOpenErrorCodeName(int $code): string
79 | {
80 | switch ($code) {
81 | case ZipArchive::ER_EXISTS: return 'ER_EXISTS';
82 | case ZipArchive::ER_INCONS: return 'ER_INCONS';
83 | case ZipArchive::ER_INVAL: return 'ER_INVAL';
84 | case ZipArchive::ER_MEMORY: return 'ER_MEMORY';
85 | case ZipArchive::ER_NOENT: return 'ER_NOENT';
86 | case ZipArchive::ER_NOZIP: return 'ER_NOZIP';
87 | case ZipArchive::ER_OPEN: return 'ER_OPEN';
88 | case ZipArchive::ER_READ: return 'ER_READ';
89 | case ZipArchive::ER_SEEK: return 'ER_SEEK';
90 | default: return 'unknown';
91 | }
92 | }
93 |
94 | protected function getTmpDir(): string
95 | {
96 | $tmp = tempnam(sys_get_temp_dir(), 'zipstreamtest');
97 | unlink($tmp);
98 | mkdir($tmp) or $this->fail('Failed to make directory');
99 |
100 | return $tmp;
101 | }
102 |
103 | /**
104 | * @return string[]
105 | */
106 | protected function getRecursiveFileList(string $path, bool $includeDirectories = false): array
107 | {
108 | $data = [];
109 | $path = (string) realpath($path);
110 | $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));
111 |
112 | $pathLen = strlen($path);
113 | foreach ($files as $file) {
114 | $filePath = $file->getRealPath();
115 |
116 | if (is_dir($filePath) && !$includeDirectories) {
117 | continue;
118 | }
119 |
120 | $data[] = substr($filePath, $pathLen + 1);
121 | }
122 |
123 | sort($data);
124 |
125 | return $data;
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/test/Zip64/DataDescriptorTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
21 | bin2hex($descriptor),
22 | '504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
23 | '11111111' . // 4 bytes; CRC-32 of uncompressed data
24 | '6666666677777777' . // 8 bytes; Compressed size
25 | '8888888899999999' // 8 bytes; Uncompressed size
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/test/Zip64/EndOfCentralDirectoryLocatorTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
21 | bin2hex($descriptor),
22 | '504b0607' . // 4 bytes; zip64 end of central dir locator signature - 0x07064b50
23 | '11111111' . // 4 bytes; number of the disk with the start of the zip64 end of central directory
24 | '3333333322222222' . // 28 bytes; relative offset of the zip64 end of central directory record
25 | '44444444' // 4 bytes;total number of disks
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/test/Zip64/EndOfCentralDirectoryTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
27 | bin2hex($descriptor),
28 | '504b0606' . // 4 bytes;zip64 end of central dir signature - 0x06064b50
29 | '2f00000000000000' . // 8 bytes; size of zip64 end of central directory record
30 | '3333' . // 2 bytes; version made by
31 | '4444' . // 2 bytes; version needed to extract
32 | '55555555' . // 4 bytes; number of this disk
33 | '66666666' . // 4 bytes; number of the disk with the start of the central directory
34 | '8888888877777777' . // 8 bytes; total number of entries in the central directory on this disk
35 | 'aaaaaaaa99999999' . // 8 bytes; total number of entries in the central directory
36 | 'ccccccccbbbbbbbb' . // 8 bytes; size of the central directory
37 | 'eeeeeeeedddddddd' . // 8 bytes; offset of start of central directory with respect to the starting disk number
38 | bin2hex('foo')
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/test/Zip64/ExtendedInformationExtraFieldTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
22 | bin2hex($extraField),
23 | '0100' . // 2 bytes; Tag for this "extra" block type
24 | '1c00' . // 2 bytes; Size of this "extra" block
25 | '6666666677777777' . // 8 bytes; Original uncompressed file size
26 | '8888888899999999' . // 8 bytes; Size of compressed data
27 | '1111111122222222' . // 8 bytes; Offset of local header record
28 | '33333333' // 4 bytes; Number of the disk on which this file starts
29 | );
30 | }
31 |
32 | public function testSerializesEmptyCorrectly(): void
33 | {
34 | $extraField = ExtendedInformationExtraField::generate();
35 |
36 | $this->assertSame(
37 | bin2hex($extraField),
38 | '0100' . // 2 bytes; Tag for this "extra" block type
39 | '0000' // 2 bytes; Size of this "extra" block
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/test/ZipStreamTest.php:
--------------------------------------------------------------------------------
1 | tempfileStream,
39 | sendHttpHeaders: false,
40 | );
41 |
42 | $zip->addFile('sample.txt', 'Sample String Data');
43 | $zip->addFile('test/sample.txt', 'More Simple Sample Data');
44 |
45 | $zip->finish();
46 |
47 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
48 |
49 | $files = $this->getRecursiveFileList($tmpDir);
50 | $this->assertSame(['sample.txt', 'test' . DIRECTORY_SEPARATOR . 'sample.txt'], $files);
51 |
52 | $this->assertStringEqualsFile($tmpDir . '/sample.txt', 'Sample String Data');
53 | $this->assertStringEqualsFile($tmpDir . '/test/sample.txt', 'More Simple Sample Data');
54 | }
55 |
56 | public function testAddFileUtf8NameComment(): void
57 | {
58 | $zip = new ZipStream(
59 | outputStream: $this->tempfileStream,
60 | sendHttpHeaders: false,
61 | );
62 |
63 | $name = 'árvíztűrő tükörfúrógép.txt';
64 | $content = 'Sample String Data';
65 | $comment =
66 | 'Filename has every special characters ' .
67 | 'from Hungarian language in lowercase. ' .
68 | 'In uppercase: ÁÍŰŐÜÖÚÓÉ';
69 |
70 | $zip->addFile(fileName: $name, data: $content, comment: $comment);
71 | $zip->finish();
72 |
73 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
74 |
75 | $files = $this->getRecursiveFileList($tmpDir);
76 | $this->assertSame([$name], $files);
77 | $this->assertStringEqualsFile($tmpDir . '/' . $name, $content);
78 |
79 | $zipArchive = new ZipArchive();
80 | $zipArchive->open($this->tempfile);
81 | $this->assertSame($comment, $zipArchive->getCommentName($name));
82 | }
83 |
84 | public function testAddFileUtf8NameNonUtfComment(): void
85 | {
86 | $zip = new ZipStream(
87 | outputStream: $this->tempfileStream,
88 | sendHttpHeaders: false,
89 | );
90 |
91 | $name = 'á.txt';
92 | $content = 'any';
93 | $comment = mb_convert_encoding('á', 'ISO-8859-2', 'UTF-8');
94 |
95 | // @see https://libzip.org/documentation/zip_file_get_comment.html
96 | //
97 | // mb_convert_encoding hasn't CP437.
98 | // nearly CP850 (DOS-Latin-1)
99 | $guessComment = mb_convert_encoding($comment, 'UTF-8', 'CP850');
100 |
101 | $zip->addFile(fileName: $name, data: $content, comment: $comment);
102 |
103 | $zip->finish();
104 |
105 | $zipArch = new ZipArchive();
106 | $zipArch->open($this->tempfile);
107 | $this->assertSame($guessComment, $zipArch->getCommentName($name));
108 | $this->assertSame($comment, $zipArch->getCommentName($name, ZipArchive::FL_ENC_RAW));
109 | }
110 |
111 | public function testAddFileWithStorageMethod(): void
112 | {
113 | $zip = new ZipStream(
114 | outputStream: $this->tempfileStream,
115 | sendHttpHeaders: false,
116 | );
117 |
118 | $zip->addFile(fileName: 'sample.txt', data: 'Sample String Data', compressionMethod: CompressionMethod::STORE);
119 | $zip->addFile(fileName: 'test/sample.txt', data: 'More Simple Sample Data');
120 | $zip->finish();
121 |
122 | $zipArchive = new ZipArchive();
123 | $zipArchive->open($this->tempfile);
124 |
125 | $sample1 = $zipArchive->statName('sample.txt');
126 | $sample12 = $zipArchive->statName('test/sample.txt');
127 | $this->assertSame($sample1['comp_method'], CompressionMethod::STORE->value);
128 | $this->assertSame($sample12['comp_method'], CompressionMethod::DEFLATE->value);
129 |
130 | $zipArchive->close();
131 | }
132 |
133 | public function testAddFileFromPath(): void
134 | {
135 | $zip = new ZipStream(
136 | outputStream: $this->tempfileStream,
137 | sendHttpHeaders: false,
138 | );
139 |
140 | [$tmpExample, $streamExample] = $this->getTmpFileStream();
141 | fwrite($streamExample, 'Sample String Data');
142 | fclose($streamExample);
143 | $zip->addFileFromPath(fileName: 'sample.txt', path: $tmpExample);
144 |
145 | [$tmpExample, $streamExample] = $this->getTmpFileStream();
146 | fwrite($streamExample, 'More Simple Sample Data');
147 | fclose($streamExample);
148 | $zip->addFileFromPath(fileName: 'test/sample.txt', path: $tmpExample);
149 |
150 | $zip->finish();
151 |
152 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
153 |
154 | $files = $this->getRecursiveFileList($tmpDir);
155 | $this->assertSame(['sample.txt', 'test' . DIRECTORY_SEPARATOR . 'sample.txt'], $files);
156 |
157 | $this->assertStringEqualsFile($tmpDir . '/sample.txt', 'Sample String Data');
158 | $this->assertStringEqualsFile($tmpDir . '/test/sample.txt', 'More Simple Sample Data');
159 |
160 | unlink($tmpExample);
161 | }
162 |
163 | public function testAddFileFromPathFileNotFoundException(): void
164 | {
165 | $this->expectException(FileNotFoundException::class);
166 |
167 | // Get ZipStream Object
168 | $zip = new ZipStream(
169 | outputStream: $this->tempfileStream,
170 | sendHttpHeaders: false,
171 | );
172 |
173 | // Trigger error by adding a file which doesn't exist
174 | $zip->addFileFromPath(fileName: 'foobar.php', path: '/foo/bar/foobar.php');
175 | }
176 |
177 | public function testAddFileFromPathFileNotReadableException(): void
178 | {
179 | $this->expectException(FileNotReadableException::class);
180 |
181 | // create new virtual filesystem
182 | $root = vfsStream::setup('vfs');
183 | // create a virtual file with no permissions
184 | $file = vfsStream::newFile('foo.txt', 0)->at($root)->setContent('bar');
185 |
186 | // Get ZipStream Object
187 | $zip = new ZipStream(
188 | outputStream: $this->tempfileStream,
189 | sendHttpHeaders: false,
190 | );
191 |
192 | $zip->addFileFromPath('foo.txt', $file->url());
193 | }
194 |
195 | public function testAddFileFromPathWithStorageMethod(): void
196 | {
197 | $zip = new ZipStream(
198 | outputStream: $this->tempfileStream,
199 | sendHttpHeaders: false,
200 | );
201 |
202 | [$tmpExample, $streamExample] = $this->getTmpFileStream();
203 | fwrite($streamExample, 'Sample String Data');
204 | fclose($streamExample);
205 | $zip->addFileFromPath(fileName: 'sample.txt', path: $tmpExample, compressionMethod: CompressionMethod::STORE);
206 |
207 | [$tmpExample, $streamExample] = $this->getTmpFileStream();
208 | fwrite($streamExample, 'More Simple Sample Data');
209 | fclose($streamExample);
210 | $zip->addFileFromPath('test/sample.txt', $tmpExample);
211 |
212 | $zip->finish();
213 |
214 | $zipArchive = new ZipArchive();
215 | $zipArchive->open($this->tempfile);
216 |
217 | $sample1 = $zipArchive->statName('sample.txt');
218 | $this->assertSame(CompressionMethod::STORE->value, $sample1['comp_method']);
219 |
220 | $sample2 = $zipArchive->statName('test/sample.txt');
221 | $this->assertSame(CompressionMethod::DEFLATE->value, $sample2['comp_method']);
222 |
223 | $zipArchive->close();
224 | }
225 |
226 | public function testAddLargeFileFromPath(): void
227 | {
228 | foreach ([CompressionMethod::DEFLATE, CompressionMethod::STORE] as $compressionMethod) {
229 | foreach ([false, true] as $zeroHeader) {
230 | foreach ([false, true] as $zip64) {
231 | if ($zeroHeader && $compressionMethod === CompressionMethod::DEFLATE) {
232 | continue;
233 | }
234 | $this->addLargeFileFileFromPath(
235 | compressionMethod: $compressionMethod,
236 | zeroHeader: $zeroHeader,
237 | zip64: $zip64
238 | );
239 | }
240 | }
241 | }
242 | }
243 |
244 | public function testAddFileFromStream(): void
245 | {
246 | $zip = new ZipStream(
247 | outputStream: $this->tempfileStream,
248 | sendHttpHeaders: false,
249 | );
250 |
251 | // In this test we can't use temporary stream to feed data
252 | // because zlib.deflate filter gives empty string before PHP 7
253 | // it works fine with file stream
254 | $streamExample = fopen(__FILE__, 'rb');
255 | $zip->addFileFromStream('sample.txt', $streamExample);
256 | fclose($streamExample);
257 |
258 | $streamExample2 = fopen('php://temp', 'wb+');
259 | fwrite($streamExample2, 'More Simple Sample Data');
260 | rewind($streamExample2); // move the pointer back to the beginning of file.
261 | $zip->addFileFromStream('test/sample.txt', $streamExample2); //, $fileOptions);
262 | fclose($streamExample2);
263 |
264 | $zip->finish();
265 |
266 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
267 |
268 | $files = $this->getRecursiveFileList($tmpDir);
269 | $this->assertSame(['sample.txt', 'test' . DIRECTORY_SEPARATOR . 'sample.txt'], $files);
270 |
271 | $this->assertStringEqualsFile(__FILE__, file_get_contents($tmpDir . '/sample.txt'));
272 | $this->assertStringEqualsFile($tmpDir . '/test/sample.txt', 'More Simple Sample Data');
273 | }
274 |
275 | public function testAddFileFromStreamUnreadableInput(): void
276 | {
277 | $this->expectException(StreamNotReadableException::class);
278 |
279 | [$tmpInput] = $this->getTmpFileStream();
280 |
281 | $zip = new ZipStream(
282 | outputStream: $this->tempfileStream,
283 | sendHttpHeaders: false,
284 | );
285 |
286 | $streamUnreadable = fopen($tmpInput, 'w');
287 |
288 | $zip->addFileFromStream('sample.json', $streamUnreadable);
289 | }
290 |
291 | public function testAddFileFromStreamBrokenOutputWrite(): void
292 | {
293 | $this->expectException(ResourceActionException::class);
294 |
295 | $outputStream = FaultInjectionResource::getResource(['stream_write']);
296 |
297 | $zip = new ZipStream(
298 | outputStream: $outputStream,
299 | sendHttpHeaders: false,
300 | );
301 |
302 | $zip->addFile('sample.txt', 'foobar');
303 | }
304 |
305 | public function testAddFileFromStreamBrokenInputRewind(): void
306 | {
307 | $this->expectException(ResourceActionException::class);
308 |
309 | $zip = new ZipStream(
310 | outputStream: $this->tempfileStream,
311 | sendHttpHeaders: false,
312 | defaultEnableZeroHeader: false,
313 | );
314 |
315 | $fileStream = FaultInjectionResource::getResource(['stream_seek']);
316 |
317 | $zip->addFileFromStream('sample.txt', $fileStream, maxSize: 0);
318 | }
319 |
320 | public function testAddFileFromStreamUnseekableInputWithoutZeroHeader(): void
321 | {
322 | $this->expectException(StreamNotSeekableException::class);
323 |
324 | $zip = new ZipStream(
325 | outputStream: $this->tempfileStream,
326 | sendHttpHeaders: false,
327 | defaultEnableZeroHeader: false,
328 | );
329 |
330 | if (file_exists('/dev/null')) {
331 | $streamUnseekable = fopen('/dev/null', 'w+');
332 | } elseif (file_exists('NUL')) {
333 | $streamUnseekable = fopen('NUL', 'w+');
334 | } else {
335 | $this->markTestSkipped('Needs file /dev/null');
336 | }
337 |
338 | $zip->addFileFromStream('sample.txt', $streamUnseekable, maxSize: 2);
339 | }
340 |
341 | public function testAddFileFromStreamUnseekableInputWithZeroHeader(): void
342 | {
343 | $zip = new ZipStream(
344 | outputStream: $this->tempfileStream,
345 | sendHttpHeaders: false,
346 | defaultEnableZeroHeader: true,
347 | defaultCompressionMethod: CompressionMethod::STORE,
348 | );
349 |
350 | $streamUnseekable = StreamWrapper::getResource(new class ('test') extends EndlessCycleStream {
351 | public function isSeekable(): bool
352 | {
353 | return false;
354 | }
355 |
356 | public function seek(int $offset, int $whence = SEEK_SET): void
357 | {
358 | throw new RuntimeException('Not seekable');
359 | }
360 | });
361 |
362 | $zip->addFileFromStream('sample.txt', $streamUnseekable, maxSize: 7);
363 |
364 | $zip->finish();
365 |
366 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
367 |
368 | $files = $this->getRecursiveFileList($tmpDir);
369 | $this->assertSame(['sample.txt'], $files);
370 |
371 | $this->assertSame(filesize($tmpDir . '/sample.txt'), 7);
372 | }
373 |
374 | public function testAddFileFromStreamWithStorageMethod(): void
375 | {
376 | $zip = new ZipStream(
377 | outputStream: $this->tempfileStream,
378 | sendHttpHeaders: false,
379 | );
380 |
381 | $streamExample = fopen('php://temp', 'wb+');
382 | fwrite($streamExample, 'Sample String Data');
383 | rewind($streamExample); // move the pointer back to the beginning of file.
384 | $zip->addFileFromStream('sample.txt', $streamExample, compressionMethod: CompressionMethod::STORE);
385 | fclose($streamExample);
386 |
387 | $streamExample2 = fopen('php://temp', 'bw+');
388 | fwrite($streamExample2, 'More Simple Sample Data');
389 | rewind($streamExample2); // move the pointer back to the beginning of file.
390 | $zip->addFileFromStream('test/sample.txt', $streamExample2, compressionMethod: CompressionMethod::DEFLATE);
391 | fclose($streamExample2);
392 |
393 | $zip->finish();
394 |
395 | $zipArchive = new ZipArchive();
396 | $zipArchive->open($this->tempfile);
397 |
398 | $sample1 = $zipArchive->statName('sample.txt');
399 | $this->assertSame(CompressionMethod::STORE->value, $sample1['comp_method']);
400 |
401 | $sample2 = $zipArchive->statName('test/sample.txt');
402 | $this->assertSame(CompressionMethod::DEFLATE->value, $sample2['comp_method']);
403 |
404 | $zipArchive->close();
405 | }
406 |
407 | public function testAddFileFromPsr7Stream(): void
408 | {
409 | $zip = new ZipStream(
410 | outputStream: $this->tempfileStream,
411 | sendHttpHeaders: false,
412 | );
413 |
414 | $body = 'Sample String Data';
415 | $response = new Response(200, [], $body);
416 |
417 | $zip->addFileFromPsr7Stream('sample.json', $response->getBody());
418 | $zip->finish();
419 |
420 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
421 |
422 | $files = $this->getRecursiveFileList($tmpDir);
423 | $this->assertSame(['sample.json'], $files);
424 | $this->assertStringEqualsFile($tmpDir . '/sample.json', $body);
425 | }
426 |
427 | #[Group('slow')]
428 | public function testAddLargeFileFromPsr7Stream(): void
429 | {
430 | $zip = new ZipStream(
431 | outputStream: $this->tempfileStream,
432 | sendHttpHeaders: false,
433 | enableZip64: true,
434 | );
435 |
436 | $zip->addFileFromPsr7Stream(
437 | fileName: 'sample.json',
438 | stream: new EndlessCycleStream('0'),
439 | maxSize: 0x100000000,
440 | compressionMethod: CompressionMethod::STORE,
441 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
442 | );
443 | $zip->finish();
444 |
445 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
446 |
447 | $files = $this->getRecursiveFileList($tmpDir);
448 | $this->assertSame(['sample.json'], $files);
449 | $this->assertFileIsReadable($tmpDir . '/sample.json');
450 | $this->assertStringStartsWith('000000', file_get_contents(filename: $tmpDir . '/sample.json', length: 20));
451 | }
452 |
453 | public function testContinueFinishedZip(): void
454 | {
455 | $this->expectException(RuntimeException::class);
456 |
457 | $zip = new ZipStream(
458 | outputStream: $this->tempfileStream,
459 | sendHttpHeaders: false,
460 | );
461 | $zip->finish();
462 |
463 | $zip->addFile('sample.txt', '1234');
464 | }
465 |
466 | #[Group('slow')]
467 | public function testManyFilesWithoutZip64(): void
468 | {
469 | $this->expectException(OverflowException::class);
470 |
471 | $zip = new ZipStream(
472 | outputStream: $this->tempfileStream,
473 | sendHttpHeaders: false,
474 | enableZip64: false,
475 | );
476 |
477 | for ($i = 0; $i <= 0xFFFF; $i++) {
478 | $zip->addFile('sample' . $i, '');
479 | }
480 |
481 | $zip->finish();
482 | }
483 |
484 | #[Group('slow')]
485 | public function testManyFilesWithZip64(): void
486 | {
487 | $zip = new ZipStream(
488 | outputStream: $this->tempfileStream,
489 | sendHttpHeaders: false,
490 | enableZip64: true,
491 | );
492 |
493 | for ($i = 0; $i <= 0xFFFF; $i++) {
494 | $zip->addFile('sample' . $i, '');
495 | }
496 |
497 | $zip->finish();
498 |
499 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
500 |
501 | $files = $this->getRecursiveFileList($tmpDir);
502 |
503 | $this->assertSame(count($files), 0x10000);
504 | }
505 |
506 | #[Group('slow')]
507 | public function testLongZipWithout64(): void
508 | {
509 | $this->expectException(OverflowException::class);
510 |
511 | $zip = new ZipStream(
512 | outputStream: $this->tempfileStream,
513 | sendHttpHeaders: false,
514 | enableZip64: false,
515 | defaultCompressionMethod: CompressionMethod::STORE,
516 | );
517 |
518 | for ($i = 0; $i < 4; $i++) {
519 | $zip->addFileFromPsr7Stream(
520 | fileName: 'sample' . $i,
521 | stream: new EndlessCycleStream('0'),
522 | maxSize: 0xFFFFFFFF,
523 | compressionMethod: CompressionMethod::STORE,
524 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
525 | );
526 | }
527 | }
528 |
529 | #[Group('slow')]
530 | public function testLongZipWith64(): void
531 | {
532 | $zip = new ZipStream(
533 | outputStream: $this->tempfileStream,
534 | sendHttpHeaders: false,
535 | enableZip64: true,
536 | defaultCompressionMethod: CompressionMethod::STORE,
537 | );
538 |
539 | for ($i = 0; $i < 4; $i++) {
540 | $zip->addFileFromPsr7Stream(
541 | fileName: 'sample' . $i,
542 | stream: new EndlessCycleStream('0'),
543 | maxSize: 0x5FFFFFFF,
544 | compressionMethod: CompressionMethod::STORE,
545 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
546 | );
547 | }
548 |
549 | $zip->finish();
550 |
551 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
552 |
553 | $files = $this->getRecursiveFileList($tmpDir);
554 | $this->assertSame(['sample0', 'sample1', 'sample2', 'sample3'], $files);
555 | }
556 |
557 | #[Group('slow')]
558 | public function testAddLargeFileWithoutZip64WithZeroHeader(): void
559 | {
560 | $this->expectException(OverflowException::class);
561 |
562 | $zip = new ZipStream(
563 | outputStream: $this->tempfileStream,
564 | sendHttpHeaders: false,
565 | enableZip64: false,
566 | defaultEnableZeroHeader: true,
567 | );
568 |
569 | $zip->addFileFromPsr7Stream(
570 | fileName: 'sample.json',
571 | stream: new EndlessCycleStream('0'),
572 | maxSize: 0x100000000,
573 | compressionMethod: CompressionMethod::STORE,
574 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
575 | );
576 | }
577 |
578 | #[Group('slow')]
579 | public function testAddsZip64HeaderWhenNeeded(): void
580 | {
581 | $zip = new ZipStream(
582 | outputStream: $this->tempfileStream,
583 | sendHttpHeaders: false,
584 | enableZip64: true,
585 | defaultEnableZeroHeader: false,
586 | );
587 |
588 | $zip->addFileFromPsr7Stream(
589 | fileName: 'sample.json',
590 | stream: new EndlessCycleStream('0'),
591 | maxSize: 0x100000000,
592 | compressionMethod: CompressionMethod::STORE,
593 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
594 | );
595 |
596 | $zip->finish();
597 |
598 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
599 | $files = $this->getRecursiveFileList($tmpDir);
600 |
601 | $this->assertSame(['sample.json'], $files);
602 | $this->assertFileContains($this->tempfile, PackField::pack(
603 | new PackField(format: 'V', value: 0x06064b50)
604 | ));
605 | }
606 |
607 | #[Group('slow')]
608 | public function testDoesNotAddZip64HeaderWhenNotNeeded(): void
609 | {
610 | $zip = new ZipStream(
611 | outputStream: $this->tempfileStream,
612 | sendHttpHeaders: false,
613 | enableZip64: true,
614 | defaultEnableZeroHeader: false,
615 | );
616 |
617 | $zip->addFileFromPsr7Stream(
618 | fileName: 'sample.json',
619 | stream: new EndlessCycleStream('0'),
620 | maxSize: 0x10,
621 | compressionMethod: CompressionMethod::STORE,
622 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
623 | );
624 |
625 | $zip->finish();
626 |
627 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
628 | $files = $this->getRecursiveFileList($tmpDir);
629 |
630 | $this->assertSame(['sample.json'], $files);
631 | $this->assertFileDoesNotContain($this->tempfile, PackField::pack(
632 | new PackField(format: 'V', value: 0x06064b50)
633 | ));
634 | }
635 |
636 | #[Group('slow')]
637 | public function testAddLargeFileWithoutZip64WithoutZeroHeader(): void
638 | {
639 | $this->expectException(OverflowException::class);
640 |
641 | $zip = new ZipStream(
642 | outputStream: $this->tempfileStream,
643 | sendHttpHeaders: false,
644 | enableZip64: false,
645 | defaultEnableZeroHeader: false,
646 | );
647 |
648 | $zip->addFileFromPsr7Stream(
649 | fileName: 'sample.json',
650 | stream: new EndlessCycleStream('0'),
651 | maxSize: 0x100000000,
652 | compressionMethod: CompressionMethod::STORE,
653 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
654 | );
655 | }
656 |
657 | public function testAddFileFromPsr7StreamWithOutputToPsr7Stream(): void
658 | {
659 | $psr7OutputStream = new ResourceStream($this->tempfileStream);
660 |
661 | $zip = new ZipStream(
662 | outputStream: $psr7OutputStream,
663 | sendHttpHeaders: false,
664 | );
665 |
666 | $body = 'Sample String Data';
667 | $response = new Response(200, [], $body);
668 |
669 | $zip->addFileFromPsr7Stream(
670 | fileName: 'sample.json',
671 | stream: $response->getBody(),
672 | compressionMethod: CompressionMethod::STORE,
673 | );
674 | $zip->finish();
675 | $psr7OutputStream->close();
676 |
677 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
678 | $files = $this->getRecursiveFileList($tmpDir);
679 |
680 | $this->assertSame(['sample.json'], $files);
681 | $this->assertStringEqualsFile($tmpDir . '/sample.json', $body);
682 | }
683 |
684 | public function testAddFileFromPsr7StreamWithFileSizeSet(): void
685 | {
686 | $zip = new ZipStream(
687 | outputStream: $this->tempfileStream,
688 | sendHttpHeaders: false,
689 | );
690 |
691 | $body = 'Sample String Data';
692 | $fileSize = strlen($body);
693 | // Add fake padding
694 | $fakePadding = "\0\0\0\0\0\0";
695 | $response = new Response(200, [], $body . $fakePadding);
696 |
697 | $zip->addFileFromPsr7Stream(
698 | fileName: 'sample.json',
699 | stream: $response->getBody(),
700 | compressionMethod: CompressionMethod::STORE,
701 | maxSize: $fileSize
702 | );
703 | $zip->finish();
704 |
705 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
706 |
707 | $files = $this->getRecursiveFileList($tmpDir);
708 | $this->assertSame(['sample.json'], $files);
709 | $this->assertStringEqualsFile($tmpDir . '/sample.json', $body);
710 | }
711 |
712 | public function testCreateArchiveHeaders(): void
713 | {
714 | $headers = [];
715 |
716 | $httpHeaderCallback = function (string $header) use (&$headers) {
717 | $headers[] = $header;
718 | };
719 |
720 | $zip = new ZipStream(
721 | outputStream: $this->tempfileStream,
722 | sendHttpHeaders: true,
723 | outputName: 'example.zip',
724 | httpHeaderCallback: $httpHeaderCallback,
725 | );
726 |
727 | $zip->addFile(
728 | fileName: 'sample.json',
729 | data: 'foo',
730 | );
731 | $zip->finish();
732 |
733 | $this->assertContains('Content-Type: application/x-zip', $headers);
734 | $this->assertContains("Content-Disposition: attachment; filename*=UTF-8''example.zip", $headers);
735 | $this->assertContains('Pragma: public', $headers);
736 | $this->assertContains('Cache-Control: public, must-revalidate', $headers);
737 | $this->assertContains('Content-Transfer-Encoding: binary', $headers);
738 | }
739 |
740 | public function testCreateArchiveWithFlushOptionSet(): void
741 | {
742 | $zip = new ZipStream(
743 | outputStream: $this->tempfileStream,
744 | flushOutput: true,
745 | sendHttpHeaders: false,
746 | );
747 |
748 | $zip->addFile('sample.txt', 'Sample String Data');
749 | $zip->addFile('test/sample.txt', 'More Simple Sample Data');
750 |
751 | $zip->finish();
752 |
753 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
754 |
755 | $files = $this->getRecursiveFileList($tmpDir);
756 | $this->assertSame(['sample.txt', 'test' . DIRECTORY_SEPARATOR . 'sample.txt'], $files);
757 |
758 | $this->assertStringEqualsFile($tmpDir . '/sample.txt', 'Sample String Data');
759 | $this->assertStringEqualsFile($tmpDir . '/test/sample.txt', 'More Simple Sample Data');
760 | }
761 |
762 | public function testCreateArchiveWithOutputBufferingOffAndFlushOptionSet(): void
763 | {
764 | // WORKAROUND (1/2): remove phpunit's output buffer in order to run test without any buffering
765 | ob_end_flush();
766 | $this->assertSame(0, ob_get_level());
767 |
768 | $zip = new ZipStream(
769 | outputStream: $this->tempfileStream,
770 | flushOutput: true,
771 | sendHttpHeaders: false,
772 | );
773 |
774 | $zip->addFile('sample.txt', 'Sample String Data');
775 |
776 | $zip->finish();
777 |
778 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
779 | $this->assertStringEqualsFile($tmpDir . '/sample.txt', 'Sample String Data');
780 |
781 | // WORKAROUND (2/2): add back output buffering so that PHPUnit doesn't complain that it is missing
782 | ob_start();
783 | }
784 |
785 | public function testAddEmptyDirectory(): void
786 | {
787 | $zip = new ZipStream(
788 | outputStream: $this->tempfileStream,
789 | sendHttpHeaders: false,
790 | );
791 |
792 | $zip->addDirectory('foo');
793 |
794 | $zip->finish();
795 |
796 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
797 |
798 | $files = $this->getRecursiveFileList($tmpDir, includeDirectories: true);
799 |
800 | $this->assertContains('foo', $files);
801 |
802 | $this->assertFileExists($tmpDir . DIRECTORY_SEPARATOR . 'foo');
803 | $this->assertDirectoryExists($tmpDir . DIRECTORY_SEPARATOR . 'foo');
804 | }
805 |
806 | public function testAddFileSimulate(): void
807 | {
808 | $create = function (OperationMode $operationMode): int {
809 | $zip = new ZipStream(
810 | sendHttpHeaders: false,
811 | operationMode: $operationMode,
812 | defaultEnableZeroHeader: true,
813 | outputStream: $this->tempfileStream,
814 | );
815 |
816 | $zip->addFile('sample.txt', 'Sample String Data');
817 | $zip->addFile('test/sample.txt', 'More Simple Sample Data');
818 |
819 | return $zip->finish();
820 | };
821 |
822 |
823 | $sizeExpected = $create(OperationMode::NORMAL);
824 | $sizeActual = $create(OperationMode::SIMULATE_LAX);
825 |
826 | $this->assertEquals($sizeExpected, $sizeActual);
827 | }
828 |
829 | public function testAddFileSimulateWithMaxSize(): void
830 | {
831 | $create = function (OperationMode $operationMode): int {
832 | $zip = new ZipStream(
833 | sendHttpHeaders: false,
834 | operationMode: $operationMode,
835 | defaultCompressionMethod: CompressionMethod::STORE,
836 | defaultEnableZeroHeader: true,
837 | outputStream: $this->tempfileStream,
838 | );
839 |
840 | $zip->addFile('sample.txt', 'Sample String Data', maxSize: 0);
841 |
842 | return $zip->finish();
843 | };
844 |
845 |
846 | $sizeExpected = $create(OperationMode::NORMAL);
847 | $sizeActual = $create(OperationMode::SIMULATE_LAX);
848 |
849 | $this->assertEquals($sizeExpected, $sizeActual);
850 | }
851 |
852 | public function testAddFileSimulateWithFstat(): void
853 | {
854 | $create = function (OperationMode $operationMode): int {
855 | $zip = new ZipStream(
856 | sendHttpHeaders: false,
857 | operationMode: $operationMode,
858 | defaultCompressionMethod: CompressionMethod::STORE,
859 | defaultEnableZeroHeader: true,
860 | outputStream: $this->tempfileStream,
861 | );
862 |
863 | $zip->addFile('sample.txt', 'Sample String Data');
864 | $zip->addFile('test/sample.txt', 'More Simple Sample Data');
865 |
866 | return $zip->finish();
867 | };
868 |
869 |
870 | $sizeExpected = $create(OperationMode::NORMAL);
871 | $sizeActual = $create(OperationMode::SIMULATE_LAX);
872 |
873 | $this->assertEquals($sizeExpected, $sizeActual);
874 | }
875 |
876 | public function testAddFileSimulateWithExactSizeZero(): void
877 | {
878 | $create = function (OperationMode $operationMode): int {
879 | $zip = new ZipStream(
880 | sendHttpHeaders: false,
881 | operationMode: $operationMode,
882 | defaultCompressionMethod: CompressionMethod::STORE,
883 | defaultEnableZeroHeader: true,
884 | outputStream: $this->tempfileStream,
885 | );
886 |
887 | $zip->addFile('sample.txt', 'Sample String Data', exactSize: 18);
888 |
889 | return $zip->finish();
890 | };
891 |
892 |
893 | $sizeExpected = $create(OperationMode::NORMAL);
894 | $sizeActual = $create(OperationMode::SIMULATE_LAX);
895 |
896 | $this->assertEquals($sizeExpected, $sizeActual);
897 | }
898 |
899 | public function testAddFileSimulateWithExactSizeInitial(): void
900 | {
901 | $create = function (OperationMode $operationMode): int {
902 | $zip = new ZipStream(
903 | sendHttpHeaders: false,
904 | operationMode: $operationMode,
905 | defaultCompressionMethod: CompressionMethod::STORE,
906 | defaultEnableZeroHeader: false,
907 | outputStream: $this->tempfileStream,
908 | );
909 |
910 | $zip->addFile('sample.txt', 'Sample String Data', exactSize: 18);
911 |
912 | return $zip->finish();
913 | };
914 |
915 | $sizeExpected = $create(OperationMode::NORMAL);
916 | $sizeActual = $create(OperationMode::SIMULATE_LAX);
917 |
918 | $this->assertEquals($sizeExpected, $sizeActual);
919 | }
920 |
921 | public function testAddFileSimulateWithZeroSizeInFstat(): void
922 | {
923 | $create = function (OperationMode $operationMode): int {
924 | $zip = new ZipStream(
925 | sendHttpHeaders: false,
926 | operationMode: $operationMode,
927 | defaultCompressionMethod: CompressionMethod::STORE,
928 | defaultEnableZeroHeader: false,
929 | outputStream: $this->tempfileStream,
930 | );
931 |
932 | $zip->addFileFromPsr7Stream('sample.txt', new class implements StreamInterface {
933 | public $pos = 0;
934 |
935 | public function __toString(): string
936 | {
937 | return 'test';
938 | }
939 |
940 | public function close(): void {}
941 |
942 | public function detach() {}
943 |
944 | public function getSize(): ?int
945 | {
946 | return null;
947 | }
948 |
949 | public function tell(): int
950 | {
951 | return $this->pos;
952 | }
953 |
954 | public function eof(): bool
955 | {
956 | return $this->pos >= 4;
957 | }
958 |
959 | public function isSeekable(): bool
960 | {
961 | return true;
962 | }
963 |
964 | public function seek(int $offset, int $whence = SEEK_SET): void
965 | {
966 | $this->pos = $offset;
967 | }
968 |
969 | public function rewind(): void
970 | {
971 | $this->pos = 0;
972 | }
973 |
974 | public function isWritable(): bool
975 | {
976 | return false;
977 | }
978 |
979 | public function write(string $string): int
980 | {
981 | return 0;
982 | }
983 |
984 | public function isReadable(): bool
985 | {
986 | return true;
987 | }
988 |
989 | public function read(int $length): string
990 | {
991 | $data = substr('test', $this->pos, $length);
992 | $this->pos += strlen($data);
993 | return $data;
994 | }
995 |
996 | public function getContents(): string
997 | {
998 | return $this->read(4);
999 | }
1000 |
1001 | public function getMetadata(?string $key = null)
1002 | {
1003 | return $key !== null ? null : [];
1004 | }
1005 | });
1006 |
1007 | return $zip->finish();
1008 | };
1009 |
1010 | $sizeExpected = $create(OperationMode::NORMAL);
1011 | $sizeActual = $create(OperationMode::SIMULATE_LAX);
1012 |
1013 |
1014 | $this->assertEquals($sizeExpected, $sizeActual);
1015 | }
1016 |
1017 | public function testAddFileSimulateWithWrongExactSize(): void
1018 | {
1019 | $this->expectException(FileSizeIncorrectException::class);
1020 |
1021 | $zip = new ZipStream(
1022 | sendHttpHeaders: false,
1023 | operationMode: OperationMode::SIMULATE_LAX,
1024 | );
1025 |
1026 | $zip->addFile('sample.txt', 'Sample String Data', exactSize: 1000);
1027 | }
1028 |
1029 | public function testAddFileSimulateStrictZero(): void
1030 | {
1031 | $this->expectException(SimulationFileUnknownException::class);
1032 |
1033 | $zip = new ZipStream(
1034 | sendHttpHeaders: false,
1035 | operationMode: OperationMode::SIMULATE_STRICT,
1036 | defaultEnableZeroHeader: true
1037 | );
1038 |
1039 | $zip->addFile('sample.txt', 'Sample String Data');
1040 | }
1041 |
1042 | public function testAddFileSimulateStrictInitial(): void
1043 | {
1044 | $this->expectException(SimulationFileUnknownException::class);
1045 |
1046 | $zip = new ZipStream(
1047 | sendHttpHeaders: false,
1048 | operationMode: OperationMode::SIMULATE_STRICT,
1049 | defaultEnableZeroHeader: false
1050 | );
1051 |
1052 | $zip->addFile('sample.txt', 'Sample String Data');
1053 | }
1054 |
1055 | public function testAddFileCallbackStrict(): void
1056 | {
1057 | $this->expectException(SimulationFileUnknownException::class);
1058 |
1059 | $zip = new ZipStream(
1060 | sendHttpHeaders: false,
1061 | operationMode: OperationMode::SIMULATE_STRICT,
1062 | defaultEnableZeroHeader: false
1063 | );
1064 |
1065 | $zip->addFileFromCallback('sample.txt', callback: function () {
1066 | return '';
1067 | });
1068 | }
1069 |
1070 | public function testAddFileCallbackLax(): void
1071 | {
1072 | $zip = new ZipStream(
1073 | operationMode: OperationMode::SIMULATE_LAX,
1074 | defaultEnableZeroHeader: false,
1075 | sendHttpHeaders: false,
1076 | );
1077 |
1078 | $zip->addFileFromCallback('sample.txt', callback: function () {
1079 | return 'Sample String Data';
1080 | });
1081 |
1082 | $size = $zip->finish();
1083 |
1084 | $this->assertEquals($size, 142);
1085 | }
1086 |
1087 | public function testExecuteSimulation(): void
1088 | {
1089 | $zip = new ZipStream(
1090 | operationMode: OperationMode::SIMULATE_STRICT,
1091 | defaultCompressionMethod: CompressionMethod::STORE,
1092 | defaultEnableZeroHeader: false,
1093 | sendHttpHeaders: false,
1094 | outputStream: $this->tempfileStream,
1095 | );
1096 |
1097 | $zip->addFileFromCallback(
1098 | 'sample.txt',
1099 | exactSize: 18,
1100 | callback: function () {
1101 | return 'Sample String Data';
1102 | }
1103 | );
1104 |
1105 | $zip->addFileFromCallback(
1106 | '.gitkeep',
1107 | exactSize: 0,
1108 | callback: function () {
1109 | return '';
1110 | }
1111 | );
1112 |
1113 | $size = $zip->finish();
1114 |
1115 | $this->assertEquals(filesize($this->tempfile), 0);
1116 |
1117 | $zip->executeSimulation();
1118 |
1119 | clearstatcache();
1120 |
1121 | $this->assertEquals(filesize($this->tempfile), $size);
1122 |
1123 | $tmpDir = $this->validateAndExtractZip($this->tempfile);
1124 |
1125 | $files = $this->getRecursiveFileList($tmpDir);
1126 | $this->assertSame(['.gitkeep', 'sample.txt'], $files);
1127 | }
1128 |
1129 | public function testExecuteSimulationBeforeFinish(): void
1130 | {
1131 | $this->expectException(RuntimeException::class);
1132 |
1133 | $zip = new ZipStream(
1134 | operationMode: OperationMode::SIMULATE_LAX,
1135 | defaultEnableZeroHeader: false,
1136 | sendHttpHeaders: false,
1137 | outputStream: $this->tempfileStream,
1138 | );
1139 |
1140 | $zip->executeSimulation();
1141 | }
1142 |
1143 | #[Group('slow')]
1144 | public function testSimulationWithLargeZip64AndZeroHeader(): void
1145 | {
1146 | $zip = new ZipStream(
1147 | outputStream: $this->tempfileStream,
1148 | sendHttpHeaders: false,
1149 | operationMode: OperationMode::SIMULATE_STRICT,
1150 | defaultCompressionMethod: CompressionMethod::STORE,
1151 | outputName: 'archive.zip',
1152 | enableZip64: true,
1153 | defaultEnableZeroHeader: true
1154 | );
1155 |
1156 | $zip->addFileFromPsr7Stream(
1157 | fileName: 'large',
1158 | stream: new EndlessCycleStream('large'),
1159 | exactSize: 0x120000000, // ~5gb
1160 | compressionMethod: CompressionMethod::STORE,
1161 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
1162 | );
1163 |
1164 | $zip->addFileFromPsr7Stream(
1165 | fileName: 'small',
1166 | stream: new EndlessCycleStream('small'),
1167 | exactSize: 0x20,
1168 | compressionMethod: CompressionMethod::STORE,
1169 | lastModificationDateTime: new DateTimeImmutable('2022-01-01 01:01:01Z'),
1170 | );
1171 |
1172 | $forecastedSize = $zip->finish();
1173 |
1174 | $zip->executeSimulation();
1175 |
1176 | $this->assertSame($forecastedSize, filesize($this->tempfile));
1177 |
1178 | $this->validateAndExtractZip($this->tempfile);
1179 | }
1180 |
1181 | private function addLargeFileFileFromPath(CompressionMethod $compressionMethod, $zeroHeader, $zip64): void
1182 | {
1183 | [$tmp, $stream] = $this->getTmpFileStream();
1184 |
1185 | $zip = new ZipStream(
1186 | outputStream: $stream,
1187 | sendHttpHeaders: false,
1188 | defaultEnableZeroHeader: $zeroHeader,
1189 | enableZip64: $zip64,
1190 | );
1191 |
1192 | [$tmpExample, $streamExample] = $this->getTmpFileStream();
1193 | for ($i = 0; $i <= 10000; $i++) {
1194 | fwrite($streamExample, sha1((string) $i));
1195 | if ($i % 100 === 0) {
1196 | fwrite($streamExample, "\n");
1197 | }
1198 | }
1199 | fclose($streamExample);
1200 | $shaExample = sha1_file($tmpExample);
1201 | $zip->addFileFromPath('sample.txt', $tmpExample);
1202 | unlink($tmpExample);
1203 |
1204 | $zip->finish();
1205 | fclose($stream);
1206 |
1207 | $tmpDir = $this->validateAndExtractZip($tmp);
1208 |
1209 | $files = $this->getRecursiveFileList($tmpDir);
1210 | $this->assertSame(['sample.txt'], $files);
1211 |
1212 | $this->assertSame(sha1_file($tmpDir . '/sample.txt'), $shaExample, "SHA-1 Mismatch Method: {$compressionMethod->value}");
1213 |
1214 | unlink($tmp);
1215 | }
1216 | }
1217 |
--------------------------------------------------------------------------------
/test/Zs/ExtendedInformationExtraFieldTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
17 | bin2hex((string) $extraField),
18 | '5356' . // 2 bytes; Tag for this "extra" block type
19 | '0000' // 2 bytes; TODO: Document
20 | );
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/test/bootstrap.php:
--------------------------------------------------------------------------------
1 |