Skip to content

Commit 3afcb02

Browse files
committed
feat: Support 7z files
1 parent 6262789 commit 3afcb02

File tree

11 files changed

+357
-0
lines changed

11 files changed

+357
-0
lines changed

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
php-version: "${{ matrix.php-version }}"
3232
extensions: mbstring
3333

34+
- name: "Install unix packages"
35+
run: "sudo apt-get update && sudo apt-get install -y 7zip"
36+
3437
- name: "Cache dependencies"
3538
uses: "actions/cache@v4"
3639
with:

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dama/doctrine-test-bundle": "^8.0",
1919
"doctrine/dbal": "^3.4",
2020
"ergebnis/phpstan-rules": "^2.2.0",
21+
"gemorroj/archive7z": "^5.3",
2122
"jangregor/phpstan-prophecy": "^1.0",
2223
"mikey179/vfsstream": "^1.6.11",
2324
"monolog/monolog": "^2.3|^3.0",
@@ -52,6 +53,7 @@
5253
"doctrine/dbal": "For schema trait",
5354
"doctrine/event-manager": "For schema trait",
5455
"dama/doctrine-test-bundle": "For schema trait, when using DAMA Static Driver",
56+
"gemorroj/archive7z": "For 7z file support",
5557
"monolog/monolog": "For http client mock trait",
5658
"riverline/multipart-parser": "For multipart file uploads",
5759
"symfony/browser-kit": "For request trait",

phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ parameters:
2929
enabled: false
3030
noExtends:
3131
classesAllowedToBeExtended:
32+
- Archive7z\Archive7z
3233
- Monolog\Handler\AbstractProcessingHandler
3334
- PHPUnit\Framework\Constraint\Constraint
3435
- RuntimeException

phpunit.xml.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
<coverage/>
55
<php>
66
<ini name="error_reporting" value="-1"/>
7+
<ini name="date.timezone" value="UTC"/>
8+
<env name="TZ" value="UTC"/>
79
</php>
810
<testsuites>
911
<testsuite name="Project Test Suite">

src/SevenZipContents/Archive7z.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brainbits\FunctionalTestHelpers\SevenZipContents;
6+
7+
use Archive7z\Archive7z as BaseArchive7z;
8+
9+
use function escapeshellarg;
10+
use function exec;
11+
use function file_exists;
12+
use function is_string;
13+
14+
final class Archive7z extends BaseArchive7z
15+
{
16+
private const array EXECUTABLES = ['7z', '7zz', '7za'];
17+
18+
private static string|null $binary7z = null;
19+
20+
public function __construct(string $filename, float|null $timeout = 60.0)
21+
{
22+
parent::__construct($filename, self::getBinary7zFromPath(), $timeout);
23+
}
24+
25+
private static function getBinary7zFromPath(): string
26+
{
27+
if (self::$binary7z) {
28+
return self::$binary7z;
29+
}
30+
31+
$binary7z = null;
32+
foreach (self::EXECUTABLES as $executable) {
33+
$resultCode = 0;
34+
$binary7z = exec('which ' . escapeshellarg($executable), result_code: $resultCode); // @phpstan-ignore-line
35+
36+
if ($resultCode === 0 && is_string($binary7z) && $binary7z !== '' && file_exists($binary7z)) {
37+
break;
38+
}
39+
}
40+
41+
self::$binary7z = self::makeBinary7z($binary7z);
42+
43+
return self::$binary7z;
44+
}
45+
}

src/SevenZipContents/FileInfo.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brainbits\FunctionalTestHelpers\SevenZipContents;
6+
7+
use DateTimeImmutable;
8+
use DateTimeZone;
9+
10+
use function array_pop;
11+
use function array_push;
12+
use function explode;
13+
use function implode;
14+
use function str_replace;
15+
use function substr;
16+
use function trim;
17+
18+
final readonly class FileInfo
19+
{
20+
private string $path;
21+
private DateTimeImmutable $lastModified;
22+
23+
public function __construct(
24+
string $path,
25+
private int $size,
26+
private int $compressedSize,
27+
private int $compression,
28+
string $lastModified,
29+
private string $crc,
30+
private string|null $comment,
31+
private bool $isDir,
32+
) {
33+
$this->path = $this->cleanPath($path);
34+
35+
$utc = new DateTimeZone('UTC');
36+
37+
$this->lastModified = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', substr($lastModified, 0, 19), $utc);
38+
}
39+
40+
public function getPath(): string
41+
{
42+
return $this->path;
43+
}
44+
45+
public function getSize(): int
46+
{
47+
if ($this->isDir) {
48+
return 0;
49+
}
50+
51+
return $this->size;
52+
}
53+
54+
public function getCompressedSize(): int
55+
{
56+
return $this->compressedSize;
57+
}
58+
59+
public function getCompression(): int
60+
{
61+
return $this->compression;
62+
}
63+
64+
public function getCrc(): string
65+
{
66+
return $this->crc;
67+
}
68+
69+
public function getComment(): string|null
70+
{
71+
return $this->comment;
72+
}
73+
74+
public function isDir(): bool
75+
{
76+
return $this->isDir;
77+
}
78+
79+
public function getLastModified(): DateTimeImmutable
80+
{
81+
return $this->lastModified;
82+
}
83+
84+
/**
85+
* Cleans up a path and removes relative parts, also strips leading slashes
86+
*/
87+
private function cleanPath(string $path): string
88+
{
89+
$path = str_replace('\\', '/', $path);
90+
$path = explode('/', $path);
91+
$newpath = [];
92+
foreach ($path as $p) {
93+
if ($p === '' || $p === '.') {
94+
continue;
95+
}
96+
97+
if ($p === '..') {
98+
array_pop($newpath);
99+
continue;
100+
}
101+
102+
array_push($newpath, $p);
103+
}
104+
105+
return trim(implode('/', $newpath), '/');
106+
}
107+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brainbits\FunctionalTestHelpers\SevenZipContents;
6+
7+
use RuntimeException;
8+
9+
use function gettype;
10+
use function sprintf;
11+
12+
final class InvalidArchive extends RuntimeException
13+
{
14+
public static function notAFile(mixed $path): self
15+
{
16+
return new self(sprintf('Path %s is not valid', $path));
17+
}
18+
19+
public static function notAStream(mixed $stream): self
20+
{
21+
return new self(sprintf('Valid stream is required, %s given', gettype($stream)));
22+
}
23+
24+
public static function zeroSize(): self
25+
{
26+
return new self('ZIPs with size zero are not supported');
27+
}
28+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brainbits\FunctionalTestHelpers\SevenZipContents;
6+
7+
use function is_file;
8+
9+
final class ZipContents
10+
{
11+
public function readFile(string $file): ZipInfo
12+
{
13+
if (!is_file($file)) {
14+
throw InvalidArchive::notAFile($file);
15+
}
16+
17+
$archive = new Archive7z($file);
18+
$info = $archive->getInfo();
19+
20+
$fileInfos = [];
21+
foreach ($archive->getEntries() as $entry) {
22+
$path = $entry->getPath();
23+
24+
$fileInfos[$path] = new FileInfo(
25+
$path,
26+
(int) $entry->getSize(),
27+
(int) $entry->getPackedSize(),
28+
(int) ((int) ($entry->getPackedSize()) * 100 / (int) $entry->getSize()),
29+
$entry->getModified(),
30+
$entry->getCrc(),
31+
$entry->getComment(),
32+
$entry->isDirectory(),
33+
);
34+
}
35+
36+
return new ZipInfo($info->getPhysicalSize(), $fileInfos);
37+
}
38+
}

src/SevenZipContents/ZipInfo.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brainbits\FunctionalTestHelpers\SevenZipContents;
6+
7+
use Countable;
8+
use Generator;
9+
use IteratorAggregate;
10+
11+
use function array_key_exists;
12+
use function array_values;
13+
use function count;
14+
15+
/** @implements IteratorAggregate<string, FileInfo> */
16+
final class ZipInfo implements Countable, IteratorAggregate
17+
{
18+
/** @param FileInfo[] $files */
19+
public function __construct(private int $size, private array $files)
20+
{
21+
}
22+
23+
public function getSize(): int
24+
{
25+
return $this->size;
26+
}
27+
28+
/** @return list<FileInfo> */
29+
public function getFiles(): array
30+
{
31+
return array_values($this->files);
32+
}
33+
34+
public function hasFile(string $path): bool
35+
{
36+
return array_key_exists($path, $this->files);
37+
}
38+
39+
public function getFile(string $path): FileInfo|null
40+
{
41+
if (!$this->hasFile($path)) {
42+
return null;
43+
}
44+
45+
return $this->files[$path];
46+
}
47+
48+
public function getIterator(): Generator
49+
{
50+
yield from $this->files;
51+
}
52+
53+
public function count(): int
54+
{
55+
return count($this->files);
56+
}
57+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brainbits\FunctionalTestHelpers\Tests\SevenZipContents;
6+
7+
use Brainbits\FunctionalTestHelpers\SevenZipContents\Archive7z;
8+
use Brainbits\FunctionalTestHelpers\SevenZipContents\FileInfo;
9+
use Brainbits\FunctionalTestHelpers\SevenZipContents\InvalidArchive;
10+
use Brainbits\FunctionalTestHelpers\SevenZipContents\ZipContents;
11+
use Brainbits\FunctionalTestHelpers\SevenZipContents\ZipInfo;
12+
use PHPUnit\Framework\Attributes\CoversClass;
13+
use PHPUnit\Framework\TestCase;
14+
15+
use function iterator_to_array;
16+
use function sprintf;
17+
18+
#[CoversClass(Archive7z::class)]
19+
#[CoversClass(FileInfo::class)]
20+
#[CoversClass(InvalidArchive::class)]
21+
#[CoversClass(ZipInfo::class)]
22+
#[CoversClass(ZipContents::class)]
23+
final class SevenZipContentsTest extends TestCase
24+
{
25+
private const FILE = __DIR__ . '/../files/test.7z';
26+
27+
public function testItNeedsFile(): void
28+
{
29+
$this->expectException(InvalidArchive::class);
30+
$this->expectExceptionMessageMatches('#Path .*/tests/SevenZipContents/foo is not valid#');
31+
32+
$zipContents = new ZipContents();
33+
$zipContents->readFile(sprintf('%s/foo', __DIR__));
34+
}
35+
36+
public function testItReadsFile(): void
37+
{
38+
$zipContents = new ZipContents();
39+
$zipInfo = $zipContents->readFile(self::FILE);
40+
41+
self::assertSame(141, $zipInfo->getSize());
42+
43+
self::assertCount(1, $zipInfo);
44+
self::assertCount(1, $zipInfo->getFiles());
45+
self::assertCount(1, iterator_to_array($zipInfo));
46+
}
47+
48+
public function testItCreatesZipInfo(): void
49+
{
50+
$zipContents = new ZipContents();
51+
$zipInfo = $zipContents->readFile(self::FILE);
52+
53+
self::assertTrue($zipInfo->hasFile('my-file.txt'));
54+
self::assertNotNull($zipInfo->getFile('my-file.txt'));
55+
self::assertNull($zipInfo->getFile('not-existing-file.txt'));
56+
}
57+
58+
public function testItCreatesFileInfo(): void
59+
{
60+
$zipContents = new ZipContents();
61+
$zipInfo = $zipContents->readFile(self::FILE);
62+
$fileInfo = $zipInfo->getFile('my-file.txt');
63+
self::assertNotNull($fileInfo);
64+
65+
self::assertSame('my-file.txt', $fileInfo->getPath());
66+
self::assertSame(7, $fileInfo->getSize());
67+
self::assertSame(11, $fileInfo->getCompressedSize());
68+
self::assertSame(157, $fileInfo->getCompression());
69+
self::assertSame('B22C9747', $fileInfo->getCrc());
70+
self::assertFalse($fileInfo->isDir());
71+
self::assertSame('2020-07-24 12:00:02', $fileInfo->getLastModified()->format('Y-m-d H:i:s'));
72+
self::assertNull($fileInfo->getComment());
73+
}
74+
}

0 commit comments

Comments
 (0)