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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,19 @@ use Phpro\ResourceStream\Factory\TmpStream;

$stream = TmpStream::create();
```

### ZipStream

Opens a single entry from a zip archive as a read-only stream.
Validates that the archive exists before opening; failures (missing entry, corrupt archive, wrong/missing password) surface as a `StreamActionFailureException` carrying the underlying PHP message.

```php
use Phpro\ResourceStream\Factory\ZipStream;

$stream = ZipStream::read('/path/to/archive.zip', 'folder/file.txt');

// For encrypted entries, pass any options accepted by the `zip` stream context:
$stream = ZipStream::read('/path/to/archive.zip', 'secret.txt', ['password' => 'secret']);
```

Note: the `zip://` stream wrapper is read-only. To write into an archive, use PHP's `ZipArchive` directly.
39 changes: 39 additions & 0 deletions src/Factory/ZipStream.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Phpro\ResourceStream\Factory;

use Phpro\ResourceStream\ErrorHandling\SafeStreamAction;
use Phpro\ResourceStream\Exception\ResourceStreamException;
use Phpro\ResourceStream\Exception\RuntimeException;
use Phpro\ResourceStream\ResourceStream;

final class ZipStream
{
public const READ_MODE = 'rb';

/**
* @param array<string, mixed> $zipOptions Options passed to stream_context_create() under the 'zip' key (e.g. ['password' => '...']).
*
* @throws RuntimeException
*
* @return ResourceStream<resource>
*/
public static function read(string $archivePath, string $entry, array $zipOptions = []): ResourceStream
{
if (!file_exists($archivePath)) {
throw ResourceStreamException::forFilePath($archivePath);
}

$uri = 'zip://'.$archivePath.'#'.$entry;
$context = [] === $zipOptions ? null : stream_context_create(['zip' => $zipOptions]);

$resource = SafeStreamAction::run(
static fn () => fopen($uri, self::READ_MODE, context: $context),
'Unable to open zip entry "'.$entry.'" in archive "'.$archivePath.'"'
);

return new ResourceStream($resource);
}
}
99 changes: 99 additions & 0 deletions tests/Unit/Factory/ZipStreamTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Phpro\ResourceStream\Tests\Unit\Factory;

use Phpro\ResourceStream\Exception\ResourceStreamException;
use Phpro\ResourceStream\Exception\StreamActionFailureException;
use Phpro\ResourceStream\Factory\ZipStream;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(ZipStream::class)]
class ZipStreamTest extends TestCase
{
private string $archivePath;
private string $encryptedArchivePath;

protected function setUp(): void
{
$this->archivePath = sys_get_temp_dir().'/rs-zip-'.uniqid().'.zip';
$zip = new \ZipArchive();
$zip->open($this->archivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
$zip->addFromString('hello.txt', 'Hello World');
$zip->addFromString('nested/file.txt', 'Nested contents');
$zip->close();

$this->encryptedArchivePath = sys_get_temp_dir().'/rs-zip-enc-'.uniqid().'.zip';
$enc = new \ZipArchive();
$enc->open($this->encryptedArchivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
$enc->setPassword('secret');
$enc->addFromString('secret.txt', 'Top Secret');
$enc->setEncryptionName('secret.txt', \ZipArchive::EM_AES_256);
$enc->close();
}

protected function tearDown(): void
{
if (file_exists($this->archivePath)) {
unlink($this->archivePath);
}
if (file_exists($this->encryptedArchivePath)) {
unlink($this->encryptedArchivePath);
}
}

#[Test]
public function it_can_not_open_unexisting_archive(): void
{
$this->expectException(ResourceStreamException::class);
$this->expectExceptionMessage('Could not read file: "/unexisting/archive.zip"');

ZipStream::read('/unexisting/archive.zip', 'hello.txt');
}

#[Test]
public function it_can_read_an_entry_from_an_archive(): void
{
$stream = ZipStream::read($this->archivePath, 'hello.txt');

self::assertTrue($stream->isOpen());
self::assertSame('Hello World', $stream->getContents());
}

#[Test]
public function it_can_read_a_nested_entry(): void
{
$stream = ZipStream::read($this->archivePath, 'nested/file.txt');

self::assertSame('Nested contents', $stream->getContents());
}

#[Test]
public function it_fails_to_open_a_missing_entry(): void
{
$this->expectException(StreamActionFailureException::class);
$this->expectExceptionMessage('Unable to open zip entry "does-not-exist.txt"');

ZipStream::read($this->archivePath, 'does-not-exist.txt');
}

#[Test]
public function it_can_read_an_encrypted_entry_with_password(): void
{
$stream = ZipStream::read($this->encryptedArchivePath, 'secret.txt', ['password' => 'secret']);

self::assertSame('Top Secret', $stream->getContents());
}

#[Test]
public function it_fails_to_read_an_encrypted_entry_without_password(): void
{
$this->expectException(StreamActionFailureException::class);

$stream = ZipStream::read($this->encryptedArchivePath, 'secret.txt');
$stream->getContents();
}
}
Loading