From 307a9016c638d2e6930032cb5a2d6c8c7674ff3f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 13:11:24 +0400 Subject: [PATCH 1/9] feat: unpack `type="archive"` preserving the archive directory structure fix: stop skipping every other entry after send() during archive extraction test: cover structure-preserving extraction and the entry-path helper `type="archive"` now extracts the whole asset keeping its internal layout instead of copying the file as-is or flattening matched files into one directory. This is required for multi-file tools whose files reference each other by relative path (e.g. a binary resolving a shared library via an `$ORIGIN/../lib` rpath), which the old flat extraction broke. A single wrapping top-level directory is stripped (like `tar --strip-components=1`); a configured `` is only used to locate the executable for version checks, not moved; `` rules act as an include filter. The extractor generator now keys each entry by its archive-relative path (forward slashes) so consumers can rebuild the tree; the pure path math (top-level strip, zip-slip guard) lives in `ArchiveEntryPath`. Along the way, `send()` already advances the generator, so the extra `next()` after it was skipping every second entry. Assisted-By: Claude Opus 4.8 (1M context) --- dload.xsd | 2 +- src/DLoad.php | 135 ++++++++++++++++++ src/Module/Archive/Archive.php | 10 +- src/Module/Archive/ArchiveEntryPath.php | 73 ++++++++++ src/Module/Archive/Internal/Archive.php | 4 +- src/Module/Archive/Internal/GzArchive.php | 9 +- src/Module/Archive/Internal/NullArchive.php | 4 +- .../Archive/Internal/PharAwareArchive.php | 13 +- src/Module/Config/Schema/Action/Type.php | 10 +- tests/Acceptance/DLoadTest.php | 50 +++++++ .../Module/Archive/ArchiveIntegrationTest.php | 89 ++++++++++++ .../Archive/API/ArchiveEntryPathTest.php | 70 +++++++++ .../Module/Archive/Internal/GzArchiveTest.php | 5 +- .../Archive/Internal/NullArchiveTest.php | 2 +- 14 files changed, 457 insertions(+), 19 deletions(-) create mode 100644 src/Module/Archive/ArchiveEntryPath.php create mode 100644 tests/Unit/Module/Archive/API/ArchiveEntryPathTest.php diff --git a/dload.xsd b/dload.xsd index 3d54415..793255e 100644 --- a/dload.xsd +++ b/dload.xsd @@ -46,7 +46,7 @@ - Download type determining how the asset is processed: binary (executable from archive), phar (PHP archive, no extraction), archive (extract all contents) + Download type determining how the asset is processed: binary (extract matched executable/files, flattened into the destination), phar (PHP archive, no extraction), archive (extract the whole asset preserving its internal directory structure; a single wrapping top-level directory is stripped, and an optional <binary> is only used to locate the executable for version checks) diff --git a/src/DLoad.php b/src/DLoad.php index 96218c8..e488799 100644 --- a/src/DLoad.php +++ b/src/DLoad.php @@ -4,6 +4,7 @@ namespace Internal\DLoad; +use Internal\DLoad\Module\Archive\ArchiveEntryPath; use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Binary\BinaryProvider; use Internal\DLoad\Module\Common\DloadResult; @@ -218,6 +219,12 @@ private function prepareExtractTask( return new DloadResult([$toFile]); } + # Archive type: unpack the whole archive into the destination, preserving the + # internal directory structure instead of flattening matched files into one folder. + if ($action->type === Type::Archive) { + return $this->extractArchive($downloadResult, $software, $destination); + } + # If no extraction rules are defined, do not extract anything # and just copy the file to the destination if ($software->files === [] && $software->binary === null) { @@ -337,6 +344,134 @@ private function shouldBeExtracted(\SplFileInfo $source, array $mapping, Path $p return [null, null]; } + /** + * Extracts the whole archive into the destination, preserving the internal directory structure. + * + * Unlike the flat extraction used for single-binary tools, this keeps relative paths intact, + * which is required for archives whose files reference each other by relative path + * (e.g. a binary resolving a shared library via an `$ORIGIN/../lib` rpath). + * + * When a single top-level directory wraps the whole archive, it is stripped (like + * `tar --strip-components=1`). When `` rules are defined, they act as an include filter + * (matched by file name); otherwise every entry is extracted. A configured `` is only + * used to locate the executable inside the extracted tree for version checks — it is not moved. + * + * @return DloadResult Result of the extraction process containing extracted files and binary + * @throws NothingExtracted When the archive turned out to be empty or nothing matched the filters + */ + private function extractArchive( + DownloadResult $downloadResult, + Software $software, + Path $destination, + ): DloadResult { + $fileInfo = $downloadResult->file; + $archive = $this->archiveFactory->create($fileInfo); + $this->logger->info('Extracting %s (preserving structure)', $fileInfo->getFilename()); + + # First pass: collect entry paths to detect a single wrapping directory to strip + $entries = []; + foreach ($archive->extract() as $relativePath => $_) { + $entries[] = $relativePath; + } + $stripPrefix = ArchiveEntryPath::commonTopLevelDirectory($entries); + + $binaryRule = $this->generateBinaryExtractionConfig($software->binary); + $hasFilters = $software->files !== []; + + $resultFiles = []; + $resultBinary = null; + + # Second pass: extract entries to their relative destinations + $extractor = $archive->extract(); + while ($extractor->valid()) { + $relativePath = $extractor->key(); + $file = $extractor->current(); + \assert($file instanceof \SplFileInfo); + + $target = $this->resolveArchiveTarget($relativePath, $stripPrefix, $destination); + if ($target === null) { + $this->logger->debug('Skipping archive entry `%s`.', $relativePath); + $extractor->next(); + continue; + } + + $isBinary = $binaryRule !== null && \preg_match($binaryRule->pattern, $file->getFilename()) === 1; + $matchedFilter = $hasFilters ? $this->matchFileRule($file, $software->files) : null; + + # With explicit rules, extract only matching entries (the binary is always kept) + if ($hasFilters && $matchedFilter === null && !$isBinary) { + $this->logger->debug('Skipping file `%s`.', $relativePath); + $extractor->next(); + continue; + } + + $this->logger->debug('Extracting %s to %s...', $relativePath, (string) $target); + FS::mkdir($target->parent()); + # `send()` performs the extraction and already advances the generator to the next entry, + # so this branch must not call `next()` afterwards. + $extractor->send(new \SplFileInfo((string) $target)); + + # Binaries get the executable bit; files honor their configured chmod + $chmod = $isBinary ? 0o755 : $matchedFilter?->chmod; + $chmod === null or @\chmod((string) $target, $chmod); + + $resultFiles[] = $target; + + if ($isBinary && $resultBinary === null && $software->binary !== null) { + # Locate the binary inside the extracted tree for version checks; keep it in place + $resultBinary = $this->binaryProvider->getLocalBinary($target->parent(), $software->binary); + } + } + + $resultFiles === [] and throw new NothingExtracted( + assetName: $fileInfo->getFilename(), + rules: $this->describeExtractionRules($software, $binaryRule), + files: $entries, + ); + + $this->output->writeln( + \sprintf( + '%d file(s) from %s have been installed into %s', + \count($resultFiles), + $downloadResult->version, + (string) $destination, + ), + ); + + return new DloadResult($resultFiles, $resultBinary); + } + + /** + * Resolves the extraction target for an archive entry, stripping the common wrapping directory + * and guarding against path traversal (zip-slip). + * + * @param non-empty-string $relativePath Entry path relative to the archive root (forward slashes) + * @param string $stripPrefix Leading directory to remove (e.g. `package-1.0/`), or an empty string + * @return Path|null Target path, or null when the entry should be skipped + */ + private function resolveArchiveTarget(string $relativePath, string $stripPrefix, Path $destination): ?Path + { + $relative = ArchiveEntryPath::relative($relativePath, $stripPrefix); + + return $relative === null ? null : $destination->join($relative); + } + + /** + * Finds the first `` rule whose pattern matches the given archive entry by its file name. + * + * @param list $filters File mapping configurations + */ + private function matchFileRule(\SplFileInfo $file, array $filters): ?File + { + foreach ($filters as $filter) { + if (\preg_match($filter->pattern, $file->getFilename()) === 1) { + return $filter; + } + } + + return null; + } + /** * Gets the destination path for file extraction, prioritizing global destination path over custom extraction path. * diff --git a/src/Module/Archive/Archive.php b/src/Module/Archive/Archive.php index 6d0a078..0310526 100644 --- a/src/Module/Archive/Archive.php +++ b/src/Module/Archive/Archive.php @@ -17,21 +17,25 @@ interface Archive * Iterate through archive files and extract them * * Iterates through all files in the archive and yields {@see \SplFileInfo} objects. + * The generator key is the path of the entry relative to the archive root, using + * forward slashes as separators (e.g. `bin/rapira`). This makes it possible to + * preserve the internal directory structure on extraction. * If a {@see \SplFileInfo} is yielded back into the generator, the file will be * extracted to the given location. * * ```php - * // Extract only specific files + * // Extract only specific files, preserving their relative paths * $archive = $factory->create(new \SplFileInfo('archive.zip')); * foreach ($archive->extract() as $path => $fileInfo) { * if (str_ends_with($path, '.php')) { - * // Extract PHP files to a specific directory - * yield new \SplFileInfo('/path/to/extract/' . basename($path)); + * // Extract PHP files keeping the archive layout + * yield new \SplFileInfo('/path/to/extract/' . $path); * } * } * ``` * * @return \Generator + * Key is the entry path relative to the archive root (forward slashes). * @throws ArchiveException */ public function extract(): \Generator; diff --git a/src/Module/Archive/ArchiveEntryPath.php b/src/Module/Archive/ArchiveEntryPath.php new file mode 100644 index 0000000..e5e27ba --- /dev/null +++ b/src/Module/Archive/ArchiveEntryPath.php @@ -0,0 +1,73 @@ + $entries Entry paths relative to the archive root (forward slashes) + * @return string The prefix to strip including the trailing slash (e.g. `package-1.0/`), + * or an empty string when there is no single wrapping directory + */ + public static function commonTopLevelDirectory(array $entries): string + { + $prefix = null; + foreach ($entries as $entry) { + $slash = \strpos($entry, '/'); + // A root-level entry means there is no single wrapping directory + if ($slash === false || $slash === 0) { + return ''; + } + + $top = \substr($entry, 0, $slash); + $prefix ??= $top; + if ($top !== $prefix) { + return ''; + } + } + + return $prefix === null ? '' : $prefix . '/'; + } + + /** + * Computes the relative destination path for an archive entry, stripping the common wrapping + * directory and guarding against path traversal (zip-slip). + * + * @param non-empty-string $entryPath Entry path relative to the archive root (forward slashes) + * @param string $stripPrefix Leading directory to remove (e.g. `package-1.0/`), or an empty string + * @return non-empty-string|null Cleaned relative path, or null when the entry must be skipped + * (empty after stripping, or it would escape the destination) + */ + public static function relative(string $entryPath, string $stripPrefix): ?string + { + $relative = $stripPrefix !== '' && \str_starts_with($entryPath, $stripPrefix) + ? \substr($entryPath, \strlen($stripPrefix)) + : $entryPath; + + $relative = \trim($relative, '/'); + if ($relative === '') { + return null; + } + + // Zip-slip guard: never allow an entry to escape the destination directory + foreach (\explode('/', $relative) as $segment) { + if ($segment === '..') { + return null; + } + } + + return $relative; + } +} diff --git a/src/Module/Archive/Internal/Archive.php b/src/Module/Archive/Internal/Archive.php index 9e77fca..fdd25b8 100644 --- a/src/Module/Archive/Internal/Archive.php +++ b/src/Module/Archive/Internal/Archive.php @@ -18,8 +18,8 @@ * public function extract(): \Generator * { * // Implementation for custom archive extraction - * foreach ($files as $file) { - * $fileTo = yield $file->getPathname() => $file; + * foreach ($files as $relativePath => $file) { + * $fileTo = yield $relativePath => $file; * // Extract file if requested * } * } diff --git a/src/Module/Archive/Internal/GzArchive.php b/src/Module/Archive/Internal/GzArchive.php index b5c6e1a..fbae781 100644 --- a/src/Module/Archive/Internal/GzArchive.php +++ b/src/Module/Archive/Internal/GzArchive.php @@ -31,7 +31,11 @@ public function extract(): \Generator try { // Derive output filename by stripping .gz extension $fileName = $this->asset->getFilename(); - $outputName = \preg_replace('/\.gz$/i', '', $fileName) ?? $fileName; + \assert($fileName !== ''); + $outputName = \preg_replace('/\.gz$/i', '', $fileName); + if ($outputName === null || $outputName === '') { + $outputName = $fileName; + } $tempPath = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . $outputName; $out = \fopen($tempPath, 'wb'); @@ -53,8 +57,9 @@ public function extract(): \Generator $fileInfo = new \SplFileInfo($tempPath); + // The archive-relative path of a single-file gzip is just the decompressed file name. /** @var \SplFileInfo|null $fileTo */ - $fileTo = yield $tempPath => $fileInfo; + $fileTo = yield $outputName => $fileInfo; if ($fileTo instanceof \SplFileInfo) { \copy($tempPath, $fileTo->getRealPath() ?: $fileTo->getPathname()); diff --git a/src/Module/Archive/Internal/NullArchive.php b/src/Module/Archive/Internal/NullArchive.php index 3b2dfda..065a2e5 100644 --- a/src/Module/Archive/Internal/NullArchive.php +++ b/src/Module/Archive/Internal/NullArchive.php @@ -31,7 +31,7 @@ public function __construct( * "Extracts" the file by yielding it as-is * * Treats the file as if it were the only item in an archive. - * The key of the yielded value is the file's path. + * The key of the yielded value is the file name (its archive-relative path). * * @return \Generator * @throws ArchiveException @@ -43,7 +43,7 @@ public function extract(): \Generator ); /** @var \SplFileInfo|null $fileTo */ - $fileTo = yield $this->file->getPathname() => $this->file; + $fileTo = yield $this->file->getFilename() => $this->file; if ($fileTo instanceof \SplFileInfo) { $sourcePath = $this->file->getRealPath() ?: $this->file->getPathname(); diff --git a/src/Module/Archive/Internal/PharAwareArchive.php b/src/Module/Archive/Internal/PharAwareArchive.php index 5d95a33..c4cd260 100644 --- a/src/Module/Archive/Internal/PharAwareArchive.php +++ b/src/Module/Archive/Internal/PharAwareArchive.php @@ -25,8 +25,8 @@ * // Usage * $archive = new CustomPharArchive(new \SplFileInfo('archive.custom')); * foreach ($archive->extract() as $path => $fileInfo) { - * // Extract to destination - * yield new \SplFileInfo('/path/to/extract/' . basename($path)); + * // Extract to destination, keeping the archive layout ($path is relative) + * yield new \SplFileInfo('/path/to/extract/' . $path); * } * ``` * @@ -55,10 +55,15 @@ public function extract(): \Generator \sprintf('Could not open "%s" for reading.', $archive->getPathname()), ); + $iterator = new \RecursiveIteratorIterator($archive); + /** @var \PharFileInfo $file */ - foreach (new \RecursiveIteratorIterator($archive) as $file) { + foreach ($iterator as $file) { + // Path of the entry relative to the archive root, using forward slashes. + $relativePath = \str_replace('\\', '/', $iterator->getSubPathname()); + /** @var \SplFileInfo|null $fileTo */ - $fileTo = yield $file->getPathname() => $file; + $fileTo = yield $relativePath => $file; $fileTo instanceof \SplFileInfo and \copy( $file->getPathname(), $fileTo->getRealPath() ?: $fileTo->getPathname(), diff --git a/src/Module/Config/Schema/Action/Type.php b/src/Module/Config/Schema/Action/Type.php index 894a146..5bb9dcb 100644 --- a/src/Module/Config/Schema/Action/Type.php +++ b/src/Module/Config/Schema/Action/Type.php @@ -24,8 +24,14 @@ enum Type: string /** * Archive extraction type. * - * Downloads and extracts entire archive contents to specified directory. - * Used for distributing multiple files, documentation, or project assets. + * Downloads and extracts the entire archive into the destination directory, preserving the + * internal directory structure. A single top-level directory wrapping the whole archive is + * stripped (like `tar --strip-components=1`). + * + * Unlike {@see self::Binary}, matched files are not flattened — this suits multi-file tools + * whose files reference each other by relative path (e.g. a binary resolving a shared library + * via an `$ORIGIN/../lib` rpath). When `` rules are given they act as an include filter; + * a configured `` is only used to locate the executable for version checks, not moved. */ case Archive = 'archive'; diff --git a/tests/Acceptance/DLoadTest.php b/tests/Acceptance/DLoadTest.php index fb4c1f9..f05b579 100644 --- a/tests/Acceptance/DLoadTest.php +++ b/tests/Acceptance/DLoadTest.php @@ -136,6 +136,36 @@ public function downloadsTrapBinary(): void Assert::true(\is_executable($expectedPharPath), 'Binary file should be executable'); } + #[Test] + public function extractsArchivePreservingStructureAndStrippingTopLevelDir(): void + { + $dload = $this->buildDLoad($this->createRoadRunnerXmlConfig()); + $dload->useMock = true; + + $downloadConfig = new DownloadConfig(); + $downloadConfig->software = 'rr'; + $downloadConfig->type = Type::Archive; + $downloadConfig->extractPath = (string) $this->destinationDir; + + $dload->addTask($downloadConfig); + $dload->run(); + + $os = OperatingSystem::fromGlobals(); + + // The single wrapping directory (roadrunner-2024.1.5-windows-amd64/) is stripped, + // so its contents land directly in the destination, keeping their relative layout. + $binaryPath = $this->destinationDir->join('rr' . $os->getBinaryExtension()); + Assert::true($binaryPath->isFile(), 'Binary should be extracted into the destination root'); + Assert::true($this->destinationDir->join('README.md')->isFile(), 'Sibling files should be extracted too'); + Assert::true($this->destinationDir->join('LICENSE')->isFile(), 'Sibling files should be extracted too'); + + // The wrapping version directory must not be recreated inside the destination. + Assert::false( + $this->destinationDir->join('roadrunner-2024.1.5-windows-amd64')->exists(), + 'The stripped top-level directory should not be present', + ); + } + #[BeforeTest] protected function prepare(): void { @@ -214,6 +244,26 @@ private function createTrapXmlConfig(): string } + /** + * @return non-empty-string + */ + private function createRoadRunnerXmlConfig(): string + { + return << + + + + + + + + + XML; + } + private function removeDirectory(Path $dir): void { if (!$dir->isDir()) { diff --git a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php index 607e54a..64869d8 100644 --- a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php +++ b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php @@ -27,6 +27,16 @@ #[Group('integration')] final class ArchiveIntegrationTest { + /** + * Files of a nested archive layout, mimicking a self-contained tool + * (a binary that resolves a shared library through a relative path). + */ + private const NESTED_LAYOUT = [ + 'pkg-1.0/bin/app' => "binary\n", + 'pkg-1.0/lib/app/libphp.so' => "shared library\n", + 'pkg-1.0/share/app/VERSION.txt' => "1.0\n", + ]; + private string $tempDir; private ArchiveFactory $factory; @@ -38,6 +48,12 @@ public static function provideArchiveTypes(): \Generator yield 'exe' => ['exe', NullArchive::class]; } + public static function provideNestedArchiveTypes(): \Generator + { + yield 'zip' => ['zip']; + yield 'tar.gz' => ['tar.gz']; + } + #[DataProvider('provideArchiveTypes')] #[Test] public function factoryCreateReturnsCorrectImplementation( @@ -81,6 +97,44 @@ public function factoryExtendWithCustomImplementation(): void Assert::same($archive, $customArchive); } + #[DataProvider('provideNestedArchiveTypes')] + #[Test] + public function extractKeysEntriesByTheirArchiveRelativePath(string $type): void + { + $archive = $this->factory->create(new \SplFileInfo($this->createNestedArchive($type))); + + $keys = []; + foreach ($archive->extract() as $relativePath => $_) { + $keys[] = $relativePath; + } + + \sort($keys); + Assert::same($keys, \array_keys(self::NESTED_LAYOUT)); + } + + #[DataProvider('provideNestedArchiveTypes')] + #[Test] + public function extractPreservesTheNestedDirectoryStructure(string $type): void + { + $archive = $this->factory->create(new \SplFileInfo($this->createNestedArchive($type))); + $target = $this->tempDir . '/extracted'; + + $extractor = $archive->extract(); + while ($extractor->valid()) { + $relativePath = $extractor->key(); + $destination = $target . '/' . $relativePath; + \is_dir(\dirname($destination)) or \mkdir(\dirname($destination), 0777, true); + // `send()` extracts the current entry and advances the generator on its own. + $extractor->send(new \SplFileInfo($destination)); + } + + foreach (self::NESTED_LAYOUT as $relativePath => $content) { + $path = $target . '/' . $relativePath; + Assert::true(\is_file($path), "Entry `{$relativePath}` should be extracted preserving its path"); + Assert::same(\file_get_contents($path), $content); + } + } + #[BeforeTest] protected function prepare(): void { @@ -107,6 +161,41 @@ protected function cleanup(): void } } + /** + * Builds a real archive of the given type with a nested directory layout. + * + * @param non-empty-string $type Either `zip` or `tar.gz` + * @return non-empty-string Path to the created archive + */ + private function createNestedArchive(string $type): string + { + if ($type === 'zip') { + if (!\class_exists(\ZipArchive::class)) { + throw new SkipTest('Zip extension is not available'); + } + + $path = $this->tempDir . '/nested.zip'; + $zip = new \ZipArchive(); + $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + foreach (self::NESTED_LAYOUT as $entry => $content) { + $zip->addFromString($entry, $content); + } + $zip->close(); + + return $path; + } + + $tarPath = $this->tempDir . '/nested.tar'; + $phar = new \PharData($tarPath); + foreach (self::NESTED_LAYOUT as $entry => $content) { + $phar->addFromString($entry, $content); + } + $phar->compress(\Phar::GZ); + unset($phar); + + return $tarPath . '.gz'; + } + /** * Recursively remove a directory and its contents */ diff --git a/tests/Unit/Module/Archive/API/ArchiveEntryPathTest.php b/tests/Unit/Module/Archive/API/ArchiveEntryPathTest.php new file mode 100644 index 0000000..177fefa --- /dev/null +++ b/tests/Unit/Module/Archive/API/ArchiveEntryPathTest.php @@ -0,0 +1,70 @@ + [ + ['pkg-1.0/bin/app', 'pkg-1.0/lib/app/lib.so', 'pkg-1.0/share/x.txt'], + 'pkg-1.0/', + ]; + yield 'no wrapping directory when entries live at the root' => [ + ['bin/app', 'lib/app/lib.so'], + '', + ]; + yield 'no wrapping directory when top levels differ' => [ + ['pkg-1.0/bin/app', 'other/lib.so'], + '', + ]; + yield 'root-level file prevents stripping' => [ + ['pkg-1.0/bin/app', 'README.md'], + '', + ]; + yield 'empty archive' => [ + [], + '', + ]; + yield 'single wrapped file' => [ + ['pkg-1.0/bin/app'], + 'pkg-1.0/', + ]; + } + + public static function provideRelativePaths(): \Generator + { + yield 'strips the common prefix' => ['pkg-1.0/bin/app', 'pkg-1.0/', 'bin/app']; + yield 'keeps path when prefix is empty' => ['bin/app', '', 'bin/app']; + yield 'keeps path when prefix does not match' => ['other/app', 'pkg-1.0/', 'other/app']; + yield 'entry equal to the prefix is dropped' => ['pkg-1.0/', 'pkg-1.0/', null]; + yield 'parent traversal is rejected' => ['pkg-1.0/../../etc/passwd', 'pkg-1.0/', null]; + yield 'embedded traversal is rejected' => ['bin/../../etc', '', null]; + } + + #[DataProvider('provideTopLevelDirectories')] + #[Test] + public function commonTopLevelDirectoryDetectsWrappingDir(array $entries, string $expected): void + { + Assert::same(ArchiveEntryPath::commonTopLevelDirectory($entries), $expected); + } + + #[DataProvider('provideRelativePaths')] + #[Test] + public function relativeStripsPrefixAndGuardsTraversal( + string $entryPath, + string $stripPrefix, + ?string $expected, + ): void { + Assert::same(ArchiveEntryPath::relative($entryPath, $stripPrefix), $expected); + } +} diff --git a/tests/Unit/Module/Archive/Internal/GzArchiveTest.php b/tests/Unit/Module/Archive/Internal/GzArchiveTest.php index 0d23718..2e2f33f 100644 --- a/tests/Unit/Module/Archive/Internal/GzArchiveTest.php +++ b/tests/Unit/Module/Archive/Internal/GzArchiveTest.php @@ -40,13 +40,14 @@ public function extractYieldsTheDecompressedFile(): void } #[Test] - public function extractKeysTheFileByItsOwnPath(): void + public function extractKeysTheFileByItsArchiveRelativeName(): void { $archive = $this->gzArchive('payload.txt.gz', 'compressed content'); $generator = $archive->extract(); - Assert::same($generator->key(), $generator->current()->getPathname()); + Assert::same($generator->key(), $generator->current()->getFilename()); + Assert::same($generator->key(), 'payload.txt'); } #[DataProvider('provideArchiveNames')] diff --git a/tests/Unit/Module/Archive/Internal/NullArchiveTest.php b/tests/Unit/Module/Archive/Internal/NullArchiveTest.php index 7458b87..d844a48 100644 --- a/tests/Unit/Module/Archive/Internal/NullArchiveTest.php +++ b/tests/Unit/Module/Archive/Internal/NullArchiveTest.php @@ -43,7 +43,7 @@ public function extractYieldsFileAsItself(): void $key = $generator->key(); $value = $generator->current(); - Assert::same($key, '/path/to/source-file'); + Assert::same($key, 'source-file'); Assert::same($value, $sourceFile); } From b806cf4fc366e89ebf7b7ada0b109663156f5e51 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 15:46:28 +0400 Subject: [PATCH 2/9] docs: document structure-preserving `type="archive"` extraction Update the README (en/ru/es/zh) type table and the dload-fetch-tool skill to describe the new archive behaviour: the whole asset is unpacked preserving its directory layout, a single wrapping top-level directory is stripped, `` acts as an include filter and `` only locates the executable. The old wording ("forces unpacking even for .phar files") no longer captures what the type does. Assisted-By: Claude Opus 4.8 (1M context) --- README-es.md | 24 ++++++++++++++++++++++-- README-ru.md | 24 ++++++++++++++++++++++-- README-zh.md | 24 ++++++++++++++++++++++-- README.md | 24 ++++++++++++++++++++++-- skills/dload-fetch-tool/SKILL.md | 25 ++++++++++++++++++++++--- 5 files changed, 110 insertions(+), 11 deletions(-) diff --git a/README-es.md b/README-es.md index fe83f03..077dc9c 100644 --- a/README-es.md +++ b/README-es.md @@ -283,14 +283,34 @@ Cuando no se especifica `type`, DLoad automáticamente usa todos los manejadores | Tipo | Comportamiento | Caso de Uso | |-----------|-------------------------------------------------------------------|----------------------------------| -| `binary` | Verificación de binarios, validación de versión, permisos de ejecución | Herramientas CLI, ejecutables | +| `binary` | Extrae el ejecutable/archivos coincidentes, aplanándolos en el directorio de destino | Herramientas CLI, ejecutables | | `phar` | Descarga archivos `.phar` como ejecutables **sin extraer** | Herramientas PHP como Psalm, PHPStan | -| `archive` | **Fuerza la extracción incluso para archivos .phar** | Cuando necesitas el contenido del archivo | +| `archive` | **Descomprime el asset completo, preservando su estructura de directorios** | Herramientas multiarchivo, paquetes de frontend, documentación | > [!NOTE] > Usa `type="phar"` para herramientas PHP que deben mantenerse como archivos `.phar`. > Usar `type="archive"` extraerá incluso archivos `.phar`. +#### Extracción de Archivos (Preservando la Estructura) + +`type="archive"` descomprime el asset **completo** en `extract-path`, manteniendo la estructura interna de directorios del archivo en lugar de aplanar los archivos coincidentes en una sola carpeta: + +- Un único directorio de nivel superior que envuelve todo el archivo se elimina, como `tar --strip-components=1` (así `pkg-1.2.3/bin/app` queda como `bin/app`). +- Los elementos ``, cuando están presentes, actúan como un **filtro de inclusión** (coincidencia por nombre de archivo); omítelos para extraer todo. +- Un ``, si está configurado, se usa **solo para localizar** el ejecutable dentro del árbol extraído (para la comprobación de versión) y marcar su bit de ejecución — no se saca de su subdirectorio. + +Esto es necesario cuando los archivos se referencian entre sí por ruta relativa — por ejemplo, un binario que resuelve una biblioteca compartida a través de un rpath `$ORIGIN/../lib`. El aplanamiento rompería ese enlace; la extracción que preserva la estructura mantiene `bin/` y `lib/` en su lugar relativo. + +```xml + + + + + + + +``` + ### Restricciones de Versión Usa restricciones de versión estilo Composer: diff --git a/README-ru.md b/README-ru.md index e194fae..a45d0c4 100644 --- a/README-ru.md +++ b/README-ru.md @@ -284,14 +284,34 @@ DLoad поддерживает три типа загрузки, которые | Тип | Поведение | Случаи использования | |-----------|----------------------------------------------------------------|--------------------------------| -| `binary` | Проверка бинарника, валидация версии, права на выполнение | CLI-инструменты, исполняемые файлы | +| `binary` | Извлекает совпавший исполняемый файл/файлы, раскладывая их плоско в папку назначения | CLI-инструменты, исполняемые файлы | | `phar` | Загружает `.phar` файлы как исполняемые **без распаковки** | PHP-инструменты вроде Psalm, PHPStan | -| `archive` | **Принудительно распаковывает даже .phar файлы** | Когда нужно содержимое архива | +| `archive` | **Распаковывает весь ассет целиком, сохраняя структуру каталогов** | Многофайловые инструменты, фронтенд-сборки, документация | > [!NOTE] > Используйте `type="phar"` для PHP-инструментов, которые должны остаться как `.phar` файлы. > Использование `type="archive"` распакует даже `.phar` архивы. +#### Распаковка архива с сохранением структуры + +`type="archive"` распаковывает **весь** ассет в `extract-path`, сохраняя внутреннюю структуру каталогов архива, а не сплющивая совпавшие файлы в одну папку: + +- Единственный верхний каталог, оборачивающий весь архив, срезается — как `tar --strip-components=1` (поэтому `pkg-1.2.3/bin/app` попадёт в `bin/app`). +- Элементы ``, если заданы, работают как **include-фильтр** (сопоставление по имени файла); опустите их, чтобы извлечь всё. +- ``, если задан, используется **только чтобы найти** исполняемый файл в распакованном дереве (для проверки версии) и выставить ему бит выполнения — он не выносится из своего подкаталога. + +Это необходимо, когда файлы ссылаются друг на друга по относительному пути — например, бинарник, который находит разделяемую библиотеку через rpath `$ORIGIN/../lib`. Сплющивание сломало бы эту связь; распаковка с сохранением структуры оставляет `bin/` и `lib/` на своих местах друг относительно друга. + +```xml + + + + + + + +``` + ### Ограничения версий Используйте ограничения версий в стиле Composer: diff --git a/README-zh.md b/README-zh.md index 1c4440c..e4d7c8d 100644 --- a/README-zh.md +++ b/README-zh.md @@ -283,14 +283,34 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: | 类型 | 行为 | 适用场景 | |-----------|--------------------------------------------------------------|--------------------------------| -| `binary` | 二进制检查、版本验证、可执行权限 | CLI 工具、可执行文件 | +| `binary` | 提取匹配的可执行文件/文件,并平铺到目标目录中 | CLI 工具、可执行文件 | | `phar` | 下载 `.phar` 文件作为可执行文件**但不解包** | PHP 工具如 Psalm、PHPStan | -| `archive` | **强制解包即使是 .phar 文件** | 当你需要压缩包内容时 | +| `archive` | **解包整个资源,保留其目录结构** | 多文件工具、前端产物、文档 | > [!NOTE] > 对于应该保持为 `.phar` 文件的 PHP 工具,使用 `type="phar"`。 > 使用 `type="archive"` 会解包甚至 `.phar` 压缩包。 +#### 解包压缩包(保留结构) + +`type="archive"` 会将**整个**资源解包到 `extract-path`,保留压缩包内部的目录结构,而不是把匹配的文件平铺到单个目录中: + +- 包裹整个压缩包的单个顶层目录会被剥离,类似 `tar --strip-components=1`(因此 `pkg-1.2.3/bin/app` 会落到 `bin/app`)。 +- `` 元素若存在,则作为**包含过滤器**(按文件名匹配);省略它们即可提取全部内容。 +- `` 若已配置,仅用于**定位**解包后目录树中的可执行文件(用于版本检查)并设置其可执行位——它不会被移出所在的子目录。 + +当文件之间通过相对路径相互引用时,这一点是必需的——例如某个二进制文件通过 `$ORIGIN/../lib` 的 rpath 解析共享库。平铺会破坏该引用;保留结构的解包会让 `bin/` 与 `lib/` 保持彼此相对的位置。 + +```xml + + + + + + + +``` + ### 版本约束 使用类似 Composer 的版本约束: diff --git a/README.md b/README.md index 0518b4b..7404537 100644 --- a/README.md +++ b/README.md @@ -285,14 +285,34 @@ When `type` is not specified, DLoad automatically uses all available handlers: | Type | Behavior | Use Case | |-----------|--------------------------------------------------------------|--------------------------------| -| `binary` | Binary checking, version validation, executable permissions | CLI tools, executables | +| `binary` | Extracts the matched executable/files, flattened into the destination | CLI tools, executables | | `phar` | Downloads `.phar` files as executables **without unpacking** | PHP tools like Psalm, PHPStan | -| `archive` | **Forces unpacking even for .phar files** | When you need archive contents | +| `archive` | **Unpacks the whole asset, preserving its directory structure** | Multi-file tools, frontend bundles, docs | > [!NOTE] > Use `type="phar"` for PHP tools that should remain as `.phar` files. > Using `type="archive"` will unpack even `.phar` archives. +#### Archive Extraction (Preserving Structure) + +`type="archive"` unpacks the **entire** asset into `extract-path`, keeping the archive's internal directory layout instead of flattening matched files into a single folder: + +- A single top-level directory wrapping the whole archive is stripped, like `tar --strip-components=1` (so `pkg-1.2.3/bin/app` lands as `bin/app`). +- `` elements, when present, act as an **include filter** (matched by file name); omit them to extract everything. +- A ``, if configured, is used **only to locate** the executable inside the extracted tree (for the version check) and to set its executable bit — it is not moved out of its subdirectory. + +This is required when files reference each other by relative path — for example a binary that resolves a shared library through an `$ORIGIN/../lib` rpath. Flattening would break that link; structure-preserving extraction keeps `bin/` and `lib/` in place relative to each other. + +```xml + + + + + + + +``` + ### Version Constraints Use Composer-style version constraints: diff --git a/skills/dload-fetch-tool/SKILL.md b/skills/dload-fetch-tool/SKILL.md index 402d287..0c2ce6c 100644 --- a/skills/dload-fetch-tool/SKILL.md +++ b/skills/dload-fetch-tool/SKILL.md @@ -113,9 +113,28 @@ A PHAR is one platform-independent `.phar` file — no extraction, no OS/arch ma On the `` side, always set `type="phar"` so dload skips archive-extraction logic (see Step 3). -### Other non-binary assets +### Archive mode — extract many files, keeping their layout -Frontend bundles, configs, anything that isn't executable — use the `` element instead of `` in the inline definition, and `type="archive"` on ``. +`type="archive"` unpacks the **whole** asset into `extract-path`, preserving the archive's internal directory structure instead of flattening matched files into one folder. Use it for anything that ships more than a single executable: frontend bundles, docs, or a binary that depends on sibling files by relative path. + +- A single top-level directory that wraps the whole archive is stripped, like `tar --strip-components=1` (so `pkg-1.2.3/bin/app` lands as `bin/app`). +- `` rules, when present, act as an **include filter** (matched by file name) — omit them to extract everything. +- A ``, if given, is used **only to locate** the executable inside the extracted tree (for the version check) and to mark it executable — it is not moved out of its subdirectory. + +```xml + + + + + + + + +``` + +This matters when files reference each other by relative path — e.g. a binary that resolves a shared library through an `$ORIGIN/../lib` rpath. Flattening would break that link; structure-preserving extraction keeps `bin/` and `lib/` in place relative to each other. + +> Need only a couple of files dropped side by side instead of the whole tree? Don't set `type="archive"` — leave the type at its default and list ``/`` rules. The default (flat) extraction pulls just the matched files, by file name, and places them directly in `extract-path`. ## Step 3 — add the `` action @@ -145,7 +164,7 @@ Useful `` attributes: | `software` | Alias or name of the tool (built-in or inline). Required. | | `version` | Composer-style constraint: `^2025.1`, `~1.0.0`, `^2.12.0-feature`, `^2.12.0@beta`, `^2.12.0-hotfix@rc`. Omit for latest stable. | | `extract-path` | Override target directory (default: project root). | -| `type` | `binary` (default for executables), `archive`, `phar`. Set when the tool isn't a plain executable — required for PHAR. | +| `type` | `binary` (default for executables), `archive` (unpack the whole asset keeping its folder layout — see [Archive mode](#archive-mode--extract-many-files-keeping-their-layout)), `phar` (required for PHAR). | Stability suffixes (`@alpha`, `@beta`, `@RC`, `@stable`) follow Composer's ordering, with `stable` as the default. From 28377b452c041aeffea2f4f9f48969e924eb3ce9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 16:01:41 +0400 Subject: [PATCH 3/9] fix(registry): narrow the built-in rapira asset-pattern to php8.5 builds The broad `/^rapira-.*/` matched every published asset; anchor on `-php8.5-` so OS/arch detection only ever sees the PHP-embedded release variants. Assisted-By: Claude Opus 4.8 (1M context) --- resources/software.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/software.json b/resources/software.json index fd1aa3a..6bb0080 100644 --- a/resources/software.json +++ b/resources/software.json @@ -247,7 +247,7 @@ { "type": "github", "uri": "rapira-rs/rapira", - "asset-pattern": "/^rapira-.*/" + "asset-pattern": "/^rapira-v.*-php8\\.5-.*/" } ], "binary": { From b6241f317c6966717143b7e60b6ebbe662fb9329 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 16:03:45 +0400 Subject: [PATCH 4/9] chore(deps): update composer.lock Refresh locked dependencies: internal/path 1.2.0 -> 1.3.0, testo/psalm and dev tooling bumps. Assisted-By: Claude Opus 4.8 (1M context) --- composer.lock | 180 ++++++++++++++++++++++++++------------------------ 1 file changed, 93 insertions(+), 87 deletions(-) diff --git a/composer.lock b/composer.lock index 6600fef..f42a557 100644 --- a/composer.lock +++ b/composer.lock @@ -144,27 +144,30 @@ }, { "name": "internal/path", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/php-internal/path.git", - "reference": "ec0ddb060a204793f1ddfb5219bb024a754df0e0" + "reference": "3eca0088117a4b2a1523a320d6ebe093e206cbaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-internal/path/zipball/ec0ddb060a204793f1ddfb5219bb024a754df0e0", - "reference": "ec0ddb060a204793f1ddfb5219bb024a754df0e0", + "url": "https://api.github.com/repos/php-internal/path/zipball/3eca0088117a4b2a1523a320d6ebe093e206cbaa", + "reference": "3eca0088117a4b2a1523a320d6ebe093e206cbaa", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { "buggregator/trap": "^1.15", + "infection/infection": "^0.33.2", + "llm/skills": "^1.7", "roxblnfk/unpoly": "^1.8.1", "spiral/code-style": "^2.3.1", - "testo/testo": "^1.0@dev", - "vimeo/psalm": "^6.13" + "testo/bridge-infection": "^0.1.6", + "testo/testo": "^0.10.21", + "vimeo/psalm": "^7" }, "type": "library", "autoload": { @@ -195,7 +198,7 @@ ], "support": { "issues": "https://github.com/php-internal/path/issues", - "source": "https://github.com/php-internal/path/tree/1.2.0" + "source": "https://github.com/php-internal/path/tree/1.3.0" }, "funding": [ { @@ -203,7 +206,7 @@ "type": "boosty" } ], - "time": "2025-12-03T11:32:09+00:00" + "time": "2026-08-12T21:55:41+00:00" }, { "name": "internal/toml", @@ -3320,19 +3323,21 @@ }, { "name": "hamcrest/hamcrest-php", - "version": "v2.1.1", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-dom": "*", "php": "^7.4|^8.0" }, "replace": { @@ -3341,13 +3346,15 @@ "kodova/hamcrest-php": "*" }, "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -3365,9 +3372,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" }, - "time": "2025-04-30T06:54:44+00:00" + "time": "2026-03-17T11:56:53+00:00" }, { "name": "kelunik/certificate", @@ -3683,29 +3690,28 @@ }, { "name": "mockery/mockery", - "version": "1.6.12", + "version": "1.6.13", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + "reference": "9cb54414cdcd2ec5ca292e7ba19dba3a3444885d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "url": "https://api.github.com/repos/mockery/mockery/zipball/9cb54414cdcd2ec5ca292e7ba19dba3a3444885d", + "reference": "9cb54414cdcd2ec5ca292e7ba19dba3a3444885d", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", + "hamcrest/hamcrest-php": "^2.0 || ^3.0", "php": ">=7.3" }, "conflict": { "phpunit/phpunit": "<8.0" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.6.17", - "symplify/easy-coding-standard": "^12.1.14" + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" }, "type": "library", "autoload": { @@ -3762,7 +3768,7 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2024-05-16T03:13:13+00:00" + "time": "2026-08-15T03:07:32+00:00" }, { "name": "netresearch/jsonmapper", @@ -3874,16 +3880,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.95.18", + "version": "v3.95.19", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb" + "reference": "bd697e5e3bb17b83d4690a7238f2c7b5a131bb12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/9b815f2ba5c581faaaec1386dcda4c16d511e6bb", - "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb", + "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/bd697e5e3bb17b83d4690a7238f2c7b5a131bb12", + "reference": "bd697e5e3bb17b83d4690a7238f2c7b5a131bb12", "shasum": "" }, "require": { @@ -3920,9 +3926,9 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/shim/issues", - "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.18" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.19" }, - "time": "2026-07-30T15:46:28+00:00" + "time": "2026-08-17T16:24:34+00:00" }, { "name": "php-http/message", @@ -4332,16 +4338,16 @@ }, { "name": "rector/rector", - "version": "2.6.1", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95" + "reference": "03cd615cdd5648abb5f10ff3a684fdb976687190" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/b8e68f058bca43e01a2e1caa51ef022d6551ed95", - "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/03cd615cdd5648abb5f10ff3a684fdb976687190", + "reference": "03cd615cdd5648abb5f10ff3a684fdb976687190", "shasum": "" }, "require": { @@ -4380,7 +4386,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.6.1" + "source": "https://github.com/rectorphp/rector/tree/2.6.2" }, "funding": [ { @@ -4388,7 +4394,7 @@ "type": "github" } ], - "time": "2026-08-03T17:30:34+00:00" + "time": "2026-08-12T06:23:05+00:00" }, { "name": "revolt/event-loop", @@ -5015,21 +5021,21 @@ }, { "name": "testo/assert", - "version": "0.1.12", + "version": "0.1.13", "source": { "type": "git", "url": "https://github.com/php-testo/assert.git", - "reference": "c53eda41f1546e6ccf2ab306831530150c4844cf" + "reference": "bca697d656c19d9cd4e7f34f5eacf90f59dee4b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/assert/zipball/c53eda41f1546e6ccf2ab306831530150c4844cf", - "reference": "c53eda41f1546e6ccf2ab306831530150c4844cf", + "url": "https://api.github.com/repos/php-testo/assert/zipball/bca697d656c19d9cd4e7f34f5eacf90f59dee4b9", + "reference": "bca697d656c19d9cd4e7f34f5eacf90f59dee4b9", "shasum": "" }, "require": { "php": ">=8.2", - "testo/testo": "0.10.39 - 1" + "testo/testo": "0.10.42 - 1" }, "type": "library", "extra": { @@ -5062,7 +5068,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/assert/tree/0.1.12" + "source": "https://github.com/php-testo/assert/tree/0.1.13" }, "funding": [ { @@ -5070,7 +5076,7 @@ "type": "boosty" } ], - "time": "2026-08-06T07:43:13+00:00" + "time": "2026-08-18T05:56:30+00:00" }, { "name": "testo/bench", @@ -5198,16 +5204,16 @@ }, { "name": "testo/bridge-rector", - "version": "0.2.2", + "version": "0.2.4", "source": { "type": "git", "url": "https://github.com/php-testo/bridge-rector.git", - "reference": "c392c434bcf13c4a1ccce986f6002e125075fdad" + "reference": "ba310f73e9f786103654bad10d4d3f922772cba5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/bridge-rector/zipball/c392c434bcf13c4a1ccce986f6002e125075fdad", - "reference": "c392c434bcf13c4a1ccce986f6002e125075fdad", + "url": "https://api.github.com/repos/php-testo/bridge-rector/zipball/ba310f73e9f786103654bad10d4d3f922772cba5", + "reference": "ba310f73e9f786103654bad10d4d3f922772cba5", "shasum": "" }, "require": { @@ -5216,10 +5222,10 @@ "rector/rector": "^2.0" }, "require-dev": { - "testo/assert": "^0.1.12", - "testo/data": "^0.1.7", + "testo/assert": "^0.1.13", + "testo/data": "^0.1.8", "testo/filter": "^0.1.6", - "testo/testo": "0.10.40 - 1" + "testo/testo": "0.10.42 - 1" }, "suggest": { "testo/testo": "To test your Rector rules inline with the bundled Testo\\Bridge\\Rector\\Testing harness." @@ -5254,7 +5260,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/bridge-rector/tree/0.2.2" + "source": "https://github.com/php-testo/bridge-rector/tree/0.2.4" }, "funding": [ { @@ -5262,26 +5268,26 @@ "type": "boosty" } ], - "time": "2026-08-10T12:39:37+00:00" + "time": "2026-08-18T05:56:45+00:00" }, { "name": "testo/bridge-symfony-console", - "version": "0.1.8", + "version": "0.1.9", "source": { "type": "git", "url": "https://github.com/php-testo/bridge-symfony-console.git", - "reference": "9927456f43b4c01c839d7e24efd7c3c8fa60e68d" + "reference": "6b264ed7f3d642f4f4d8366223d764f83760d67c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/bridge-symfony-console/zipball/9927456f43b4c01c839d7e24efd7c3c8fa60e68d", - "reference": "9927456f43b4c01c839d7e24efd7c3c8fa60e68d", + "url": "https://api.github.com/repos/php-testo/bridge-symfony-console/zipball/6b264ed7f3d642f4f4d8366223d764f83760d67c", + "reference": "6b264ed7f3d642f4f4d8366223d764f83760d67c", "shasum": "" }, "require": { "php": ">=8.2", "symfony/console": "^6.4 || ^7 || ^8.0", - "testo/testo": "0.10.33 - 1" + "testo/testo": "0.10.41 - 1" }, "bin": [ "bin/testo" @@ -5314,7 +5320,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/bridge-symfony-console/tree/0.1.8" + "source": "https://github.com/php-testo/bridge-symfony-console/tree/0.1.9" }, "funding": [ { @@ -5322,28 +5328,28 @@ "type": "boosty" } ], - "time": "2026-06-29T20:42:34+00:00" + "time": "2026-08-17T12:59:32+00:00" }, { "name": "testo/codecov", - "version": "0.1.12", + "version": "0.2.0", "source": { "type": "git", "url": "https://github.com/php-testo/codecov.git", - "reference": "6e82b139788739fab2a94e52b56b0b26194c11ba" + "reference": "1e3a49f2d3d38700ae7fec815e8131541d628c9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/codecov/zipball/6e82b139788739fab2a94e52b56b0b26194c11ba", - "reference": "6e82b139788739fab2a94e52b56b0b26194c11ba", + "url": "https://api.github.com/repos/php-testo/codecov/zipball/1e3a49f2d3d38700ae7fec815e8131541d628c9e", + "reference": "1e3a49f2d3d38700ae7fec815e8131541d628c9e", "shasum": "" }, "require": { "ext-xmlwriter": "*", "php": ">=8.2", - "testo/data": "^0.1.7", - "testo/inline": "^0.1.7", - "testo/testo": "0.10.39 - 1" + "testo/data": "^0.1.8", + "testo/inline": "^0.1.8", + "testo/testo": "0.10.41 - 1" }, "type": "library", "extra": { @@ -5372,7 +5378,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/codecov/tree/0.1.12" + "source": "https://github.com/php-testo/codecov/tree/0.2.0" }, "funding": [ { @@ -5380,7 +5386,7 @@ "type": "boosty" } ], - "time": "2026-08-06T07:45:32+00:00" + "time": "2026-08-17T12:59:29+00:00" }, { "name": "testo/convention", @@ -5439,22 +5445,22 @@ }, { "name": "testo/data", - "version": "0.1.7", + "version": "0.1.8", "source": { "type": "git", "url": "https://github.com/php-testo/data.git", - "reference": "ca70f7b7c29bab22b6abb4e00783b80c18551b5a" + "reference": "2221906f39297c114f5eaa7a177d03fbe47dc7e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/data/zipball/ca70f7b7c29bab22b6abb4e00783b80c18551b5a", - "reference": "ca70f7b7c29bab22b6abb4e00783b80c18551b5a", + "url": "https://api.github.com/repos/php-testo/data/zipball/2221906f39297c114f5eaa7a177d03fbe47dc7e0", + "reference": "2221906f39297c114f5eaa7a177d03fbe47dc7e0", "shasum": "" }, "require": { "php": ">=8.2", "testo/filter": "^0.1.6", - "testo/testo": "0.10.39 - 1" + "testo/testo": "0.10.41 - 1" }, "type": "library", "extra": { @@ -5483,7 +5489,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/data/tree/0.1.7" + "source": "https://github.com/php-testo/data/tree/0.1.8" }, "funding": [ { @@ -5491,7 +5497,7 @@ "type": "boosty" } ], - "time": "2026-08-06T07:43:21+00:00" + "time": "2026-08-17T12:59:30+00:00" }, { "name": "testo/fiber", @@ -5899,34 +5905,34 @@ }, { "name": "testo/testo", - "version": "0.10.40", + "version": "0.10.42", "source": { "type": "git", "url": "https://github.com/php-testo/testo.git", - "reference": "390425b1f0d2c7560e1acc00e09da2ef7a7b5eef" + "reference": "b22857b7eff9b1b0d0f87e6a9f33c702724cdcce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/testo/zipball/390425b1f0d2c7560e1acc00e09da2ef7a7b5eef", - "reference": "390425b1f0d2c7560e1acc00e09da2ef7a7b5eef", + "url": "https://api.github.com/repos/php-testo/testo/zipball/b22857b7eff9b1b0d0f87e6a9f33c702724cdcce", + "reference": "b22857b7eff9b1b0d0f87e6a9f33c702724cdcce", "shasum": "" }, "require": { "ext-tokenizer": "*", "internal/destroy": "^1.0", - "internal/path": "^1.2", + "internal/path": "^1.3", "php": ">=8.2", "psr/container": "1 - 2", "psr/event-dispatcher": "^1.0", "psr/log": "^2.0 || ^3.0", "symfony/console": "^6.4 || ^7 || ^8.0", "symfony/finder": "^6.4 || ^7 || ^8.0", - "testo/assert": "^0.1.12", + "testo/assert": "^0.1.13", "testo/bench": "^0.1.8", - "testo/bridge-symfony-console": "^0.1.8", - "testo/codecov": "^0.1.12", + "testo/bridge-symfony-console": "^0.1.9", + "testo/codecov": "^0.2.0", "testo/convention": "^0.1.4", - "testo/data": "^0.1.7", + "testo/data": "^0.1.8", "testo/fiber": "^0.1.2", "testo/filter": "^0.1.6", "testo/inline": "^0.1.8", @@ -5950,7 +5956,7 @@ "roxblnfk/unpoly": "1.8.2", "testo/bridge-infection": "^0.1.8", "testo/bridge-mockery": "^0.1.2", - "testo/bridge-rector": "^0.2.2", + "testo/bridge-rector": "^0.2.4", "testo/bridge-revolt": "^0.1.1", "testo/bridge-vcr": "^0.1.0", "testo/facade": "^0.1.1" @@ -6007,7 +6013,7 @@ "type": "boosty" } ], - "time": "2026-08-10T12:38:50+00:00" + "time": "2026-08-18T05:55:42+00:00" }, { "name": "vimeo/psalm", From e2bb4eb258fe47c78e317bb28a20d4a2e383c17a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 16:09:48 +0400 Subject: [PATCH 5/9] fix: read phar entries via getContent() so tar.gz extraction works on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test: assert extracted archive entries by their in-archive names Copying a compressed tar entry through the `phar://` stream produced an empty file on Linux (green on Windows), so structure-preserving extraction wrote 0-byte files; `PharFileInfo::getContent()` decompresses reliably across platforms. The acceptance test also asserted the host-OS binary name (`rr` + extension) against the Windows-only mock asset, which never matches `rr.exe` on Linux — archive mode never renames, so assert the in-archive names instead. Assisted-By: Claude Opus 4.8 (1M context) --- src/Module/Archive/Internal/PharAwareArchive.php | 13 +++++++++---- tests/Acceptance/DLoadTest.php | 13 +++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Module/Archive/Internal/PharAwareArchive.php b/src/Module/Archive/Internal/PharAwareArchive.php index c4cd260..094ceb3 100644 --- a/src/Module/Archive/Internal/PharAwareArchive.php +++ b/src/Module/Archive/Internal/PharAwareArchive.php @@ -64,10 +64,15 @@ public function extract(): \Generator /** @var \SplFileInfo|null $fileTo */ $fileTo = yield $relativePath => $file; - $fileTo instanceof \SplFileInfo and \copy( - $file->getPathname(), - $fileTo->getRealPath() ?: $fileTo->getPathname(), - ); + + if ($fileTo instanceof \SplFileInfo) { + $destination = $fileTo->getRealPath() ?: $fileTo->getPathname(); + // Read the entry via PharFileInfo::getContent(): unlike copy() over the phar:// + // stream, it reliably decompresses tar.gz/zip entries across platforms. + $file instanceof \PharFileInfo + ? \file_put_contents($destination, $file->getContent()) + : \copy($file->getPathname(), $destination); + } } } diff --git a/tests/Acceptance/DLoadTest.php b/tests/Acceptance/DLoadTest.php index f05b579..c3d0202 100644 --- a/tests/Acceptance/DLoadTest.php +++ b/tests/Acceptance/DLoadTest.php @@ -150,12 +150,13 @@ public function extractsArchivePreservingStructureAndStrippingTopLevelDir(): voi $dload->addTask($downloadConfig); $dload->run(); - $os = OperatingSystem::fromGlobals(); - - // The single wrapping directory (roadrunner-2024.1.5-windows-amd64/) is stripped, - // so its contents land directly in the destination, keeping their relative layout. - $binaryPath = $this->destinationDir->join('rr' . $os->getBinaryExtension()); - Assert::true($binaryPath->isFile(), 'Binary should be extracted into the destination root'); + // The single wrapping directory (roadrunner-2024.1.5-windows-amd64/) is stripped, so its + // contents land directly in the destination, keeping their relative layout. Archive mode + // never renames entries, so the files keep their in-archive names regardless of host OS. + Assert::true( + $this->destinationDir->join('rr.exe')->isFile(), + 'Binary should be extracted into the destination root', + ); Assert::true($this->destinationDir->join('README.md')->isFile(), 'Sibling files should be extracted too'); Assert::true($this->destinationDir->join('LICENSE')->isFile(), 'Sibling files should be extracted too'); From a1421a30f0c82d4ea232679643351696962d68b3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 16:14:17 +0400 Subject: [PATCH 6/9] test: build the tar.gz fixture from real files so it reads back on Linux `PharData::addFromString()` on a tar produced entries that read back empty once the archive was gzip-compressed and reopened on Linux (green on Windows), failing the structure-preserving extraction test. Build the tar from real on-disk files via `buildFromDirectory()` and drop the intermediate uncompressed tar so only the `.tar.gz` remains. Assisted-By: Claude Opus 4.8 (1M context) --- .../Module/Archive/ArchiveIntegrationTest.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php index 64869d8..414735f 100644 --- a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php +++ b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php @@ -185,13 +185,22 @@ private function createNestedArchive(string $type): string return $path; } - $tarPath = $this->tempDir . '/nested.tar'; - $phar = new \PharData($tarPath); + // Build the tar from real on-disk files: `PharData::addFromString()` on a tar can produce + // entries that read back empty once the archive is gzip-compressed and reopened on Linux. + $sourceDir = $this->tempDir . '/nested-src'; foreach (self::NESTED_LAYOUT as $entry => $content) { - $phar->addFromString($entry, $content); + $file = $sourceDir . '/' . $entry; + \is_dir(\dirname($file)) or \mkdir(\dirname($file), 0777, true); + \file_put_contents($file, $content); } + + $tarPath = $this->tempDir . '/nested.tar'; + $phar = new \PharData($tarPath); + $phar->buildFromDirectory($sourceDir); $phar->compress(\Phar::GZ); unset($phar); + // Drop the intermediate uncompressed tar so only the .tar.gz remains. + \is_file($tarPath) and \unlink($tarPath); return $tarPath . '.gz'; } From 4117321ce9ccfd5458508262908edd02cd85f645 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 16:22:27 +0400 Subject: [PATCH 7/9] test: use committed archive fixtures for nested-structure extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tar.gz created via PharData and reopened within the same process reads its entries back as empty on Linux (green on Windows), so building the fixture on the fly made the structure-preserving extraction test fail in CI. Commit pre-built nested.zip and nested.tar.gz fixtures and read them instead — mirroring how real downloaded archives behave. Assisted-By: Claude Opus 4.8 (1M context) --- .../Module/Archive/ArchiveIntegrationTest.php | 49 ++++-------------- .../Module/Archive/Fixture/nested.tar.gz | Bin 0 -> 197 bytes .../Module/Archive/Fixture/nested.zip | Bin 0 -> 414 bytes 3 files changed, 10 insertions(+), 39 deletions(-) create mode 100644 tests/Integration/Module/Archive/Fixture/nested.tar.gz create mode 100644 tests/Integration/Module/Archive/Fixture/nested.zip diff --git a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php index 414735f..552aac1 100644 --- a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php +++ b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php @@ -101,7 +101,7 @@ public function factoryExtendWithCustomImplementation(): void #[Test] public function extractKeysEntriesByTheirArchiveRelativePath(string $type): void { - $archive = $this->factory->create(new \SplFileInfo($this->createNestedArchive($type))); + $archive = $this->factory->create(new \SplFileInfo($this->nestedArchiveFixture($type))); $keys = []; foreach ($archive->extract() as $relativePath => $_) { @@ -116,7 +116,7 @@ public function extractKeysEntriesByTheirArchiveRelativePath(string $type): void #[Test] public function extractPreservesTheNestedDirectoryStructure(string $type): void { - $archive = $this->factory->create(new \SplFileInfo($this->createNestedArchive($type))); + $archive = $this->factory->create(new \SplFileInfo($this->nestedArchiveFixture($type))); $target = $this->tempDir . '/extracted'; $extractor = $archive->extract(); @@ -162,47 +162,18 @@ protected function cleanup(): void } /** - * Builds a real archive of the given type with a nested directory layout. + * Returns the path to a committed archive fixture with a nested directory layout. + * + * The fixtures are pre-built and committed rather than created in-process: a tar.gz created + * and reopened within the same process reads its entries back as empty on Linux, so building + * the fixture on the fly is unreliable across platforms. * * @param non-empty-string $type Either `zip` or `tar.gz` - * @return non-empty-string Path to the created archive + * @return non-empty-string Path to the fixture archive */ - private function createNestedArchive(string $type): string + private function nestedArchiveFixture(string $type): string { - if ($type === 'zip') { - if (!\class_exists(\ZipArchive::class)) { - throw new SkipTest('Zip extension is not available'); - } - - $path = $this->tempDir . '/nested.zip'; - $zip = new \ZipArchive(); - $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); - foreach (self::NESTED_LAYOUT as $entry => $content) { - $zip->addFromString($entry, $content); - } - $zip->close(); - - return $path; - } - - // Build the tar from real on-disk files: `PharData::addFromString()` on a tar can produce - // entries that read back empty once the archive is gzip-compressed and reopened on Linux. - $sourceDir = $this->tempDir . '/nested-src'; - foreach (self::NESTED_LAYOUT as $entry => $content) { - $file = $sourceDir . '/' . $entry; - \is_dir(\dirname($file)) or \mkdir(\dirname($file), 0777, true); - \file_put_contents($file, $content); - } - - $tarPath = $this->tempDir . '/nested.tar'; - $phar = new \PharData($tarPath); - $phar->buildFromDirectory($sourceDir); - $phar->compress(\Phar::GZ); - unset($phar); - // Drop the intermediate uncompressed tar so only the .tar.gz remains. - \is_file($tarPath) and \unlink($tarPath); - - return $tarPath . '.gz'; + return __DIR__ . '/Fixture/nested.' . $type; } /** diff --git a/tests/Integration/Module/Archive/Fixture/nested.tar.gz b/tests/Integration/Module/Archive/Fixture/nested.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..e0a680d2393e712ef535f0141ce50094c01f52ae GIT binary patch literal 197 zcmV;$06PC4iwFP!000003hk9m3c@fDM!WVDy@2UVjmZgIxKU7WpQ6~J*pNif+nZF~ z1fiS$2%WbH%;p=IycvBt&yhh^RJTlNo%8{Cgb+XLZ)AeYoFi}vIS+LSlBdv1HxYO1 zl<|>6Wc?w2?#eu8W4A$!g#P${t%~4(cy+0ntvk$U^&eyZ5u`-&e*kQ$%;}i=u^ZY0 z6XffE^ADO0a9Hj3>+OcQhwCV#{r>aVe_RMb{ttkkJxNax1TjHgi3-Ec01yBGpvGSd literal 0 HcmV?d00001 diff --git a/tests/Integration/Module/Archive/Fixture/nested.zip b/tests/Integration/Module/Archive/Fixture/nested.zip new file mode 100644 index 0000000000000000000000000000000000000000..083c6f6c61e3beb181347e54e6859a6eedcc8923 GIT binary patch literal 414 zcmWIWW@Zs#0D(zOLa|?ks-4(@Y!K!L;)3jST|+$s{iMu1{ltO-AeC5D$rXU2r@Li- z7f3A#OCssX$xH(2(Ff8683lU9`NbKDMX4zYKz6KKJ0G$MM1S69@Gj68?%>aXKjUWomBj}pZ{Q%L*z_6{+1W7Z7kI;=l_XNV2 ZDkNjDdMUu0l?|kv83@k<>75`B0{|NQS;_za literal 0 HcmV?d00001 From 9d2bcba1cbcbfc38212b82919054a9b8b64c9ca8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 16:39:34 +0400 Subject: [PATCH 8/9] perf: list archive entries without extracting to avoid a second decompression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detecting the wrapping top-level directory for `type="archive"` iterated the whole extract() generator once just to read entry names, which for a `.gz` asset decompressed it an extra time (and left a temp file behind). Add Archive::entries() — a cheap paths-only listing: gzip/single-file variants return the name without touching the stream, phar-based archives read only the manifest — and use it for strip detection instead of a throwaway extraction pass. Addresses the Copilot review note. Everything here is internal to dload, so adding the interface method is not a BC break. Assisted-By: Claude Opus 4.8 (1M context) --- src/DLoad.php | 9 ++---- src/Module/Archive/Archive.php | 12 ++++++++ src/Module/Archive/Internal/GzArchive.php | 28 ++++++++++++++----- src/Module/Archive/Internal/NullArchive.php | 8 ++++++ .../Archive/Internal/PharAwareArchive.php | 18 ++++++++++++ .../Module/Archive/ArchiveIntegrationTest.php | 12 ++++++++ .../Module/Archive/Internal/ArchiveTest.php | 5 ++++ .../Module/Archive/Internal/GzArchiveTest.php | 8 ++++++ .../Archive/Internal/NullArchiveTest.php | 13 +++++++++ .../Unit/Module/Archive/Stub/TestArchive.php | 10 +++++++ 10 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/DLoad.php b/src/DLoad.php index e488799..b05d6be 100644 --- a/src/DLoad.php +++ b/src/DLoad.php @@ -368,11 +368,8 @@ private function extractArchive( $archive = $this->archiveFactory->create($fileInfo); $this->logger->info('Extracting %s (preserving structure)', $fileInfo->getFilename()); - # First pass: collect entry paths to detect a single wrapping directory to strip - $entries = []; - foreach ($archive->extract() as $relativePath => $_) { - $entries[] = $relativePath; - } + # List entry paths (without extracting) to detect a single wrapping directory to strip + $entries = $archive->entries(); $stripPrefix = ArchiveEntryPath::commonTopLevelDirectory($entries); $binaryRule = $this->generateBinaryExtractionConfig($software->binary); @@ -381,7 +378,7 @@ private function extractArchive( $resultFiles = []; $resultBinary = null; - # Second pass: extract entries to their relative destinations + # Extract entries to their relative destinations $extractor = $archive->extract(); while ($extractor->valid()) { $relativePath = $extractor->key(); diff --git a/src/Module/Archive/Archive.php b/src/Module/Archive/Archive.php index 0310526..d6706d6 100644 --- a/src/Module/Archive/Archive.php +++ b/src/Module/Archive/Archive.php @@ -39,4 +39,16 @@ interface Archive * @throws ArchiveException */ public function extract(): \Generator; + + /** + * List the paths of all entries in the archive, relative to its root, without extracting them. + * + * Cheaper than iterating {@see self::extract()} when only the layout is needed (e.g. to detect + * a common wrapping directory before extraction): single-file archives such as gzip or a + * non-archived file return the entry name without decompressing anything. + * + * @return list Entry paths relative to the archive root (forward slashes) + * @throws ArchiveException + */ + public function entries(): array; } diff --git a/src/Module/Archive/Internal/GzArchive.php b/src/Module/Archive/Internal/GzArchive.php index fbae781..0d41be3 100644 --- a/src/Module/Archive/Internal/GzArchive.php +++ b/src/Module/Archive/Internal/GzArchive.php @@ -29,13 +29,7 @@ public function extract(): \Generator ); try { - // Derive output filename by stripping .gz extension - $fileName = $this->asset->getFilename(); - \assert($fileName !== ''); - $outputName = \preg_replace('/\.gz$/i', '', $fileName); - if ($outputName === null || $outputName === '') { - $outputName = $fileName; - } + $outputName = $this->outputName(); $tempPath = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . $outputName; $out = \fopen($tempPath, 'wb'); @@ -68,4 +62,24 @@ public function extract(): \Generator \gzclose($gz); } } + + public function entries(): array + { + // The single decompressed file name — derived without touching the gzip stream. + return [$this->outputName()]; + } + + /** + * Derives the decompressed file name by stripping the `.gz` extension. + * + * @return non-empty-string + */ + private function outputName(): string + { + $fileName = $this->asset->getFilename(); + \assert($fileName !== ''); + $outputName = \preg_replace('/\.gz$/i', '', $fileName); + + return $outputName === null || $outputName === '' ? $fileName : $outputName; + } } diff --git a/src/Module/Archive/Internal/NullArchive.php b/src/Module/Archive/Internal/NullArchive.php index 065a2e5..e13b42d 100644 --- a/src/Module/Archive/Internal/NullArchive.php +++ b/src/Module/Archive/Internal/NullArchive.php @@ -52,4 +52,12 @@ public function extract(): \Generator \copy($sourcePath, $destPath); } } + + public function entries(): array + { + $name = $this->file->getFilename(); + \assert($name !== ''); + + return [$name]; + } } diff --git a/src/Module/Archive/Internal/PharAwareArchive.php b/src/Module/Archive/Internal/PharAwareArchive.php index 094ceb3..aedb569 100644 --- a/src/Module/Archive/Internal/PharAwareArchive.php +++ b/src/Module/Archive/Internal/PharAwareArchive.php @@ -76,6 +76,24 @@ public function extract(): \Generator } } + public function entries(): array + { + $archive = $this->open($this->asset); + $archive->isReadable() or throw new ArchiveException( + \sprintf('Could not open "%s" for reading.', $archive->getPathname()), + ); + + // Reading the manifest names does not decompress the entries' contents. + $entries = []; + $iterator = new \RecursiveIteratorIterator($archive); + foreach ($iterator as $_) { + $relativePath = \str_replace('\\', '/', $iterator->getSubPathname()); + $relativePath === '' or $entries[] = $relativePath; + } + + return $entries; + } + /** * Opens archive with specific format * diff --git a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php index 552aac1..2853ca8 100644 --- a/tests/Integration/Module/Archive/ArchiveIntegrationTest.php +++ b/tests/Integration/Module/Archive/ArchiveIntegrationTest.php @@ -112,6 +112,18 @@ public function extractKeysEntriesByTheirArchiveRelativePath(string $type): void Assert::same($keys, \array_keys(self::NESTED_LAYOUT)); } + #[DataProvider('provideNestedArchiveTypes')] + #[Test] + public function entriesListsArchiveRelativePaths(string $type): void + { + $archive = $this->factory->create(new \SplFileInfo($this->nestedArchiveFixture($type))); + + $entries = $archive->entries(); + \sort($entries); + + Assert::same($entries, \array_keys(self::NESTED_LAYOUT)); + } + #[DataProvider('provideNestedArchiveTypes')] #[Test] public function extractPreservesTheNestedDirectoryStructure(string $type): void diff --git a/tests/Unit/Module/Archive/Internal/ArchiveTest.php b/tests/Unit/Module/Archive/Internal/ArchiveTest.php index 41d1a06..e543355 100644 --- a/tests/Unit/Module/Archive/Internal/ArchiveTest.php +++ b/tests/Unit/Module/Archive/Internal/ArchiveTest.php @@ -61,6 +61,11 @@ public function extract(): \Generator // Minimal implementation for testing the constructor yield 'test' => new \SplFileInfo('test'); } + + public function entries(): array + { + return ['test']; + } }; } } diff --git a/tests/Unit/Module/Archive/Internal/GzArchiveTest.php b/tests/Unit/Module/Archive/Internal/GzArchiveTest.php index 2e2f33f..4eddc71 100644 --- a/tests/Unit/Module/Archive/Internal/GzArchiveTest.php +++ b/tests/Unit/Module/Archive/Internal/GzArchiveTest.php @@ -50,6 +50,14 @@ public function extractKeysTheFileByItsArchiveRelativeName(): void Assert::same($generator->key(), 'payload.txt'); } + #[Test] + public function entriesReturnsTheDecompressedNameWithoutTouchingTheStream(): void + { + $archive = $this->gzArchive('payload.txt.gz', 'compressed content'); + + Assert::same($archive->entries(), ['payload.txt']); + } + #[DataProvider('provideArchiveNames')] #[Test] public function extractStripsTheGzExtensionFromTheOutputName(string $archiveName, string $expectedName): void diff --git a/tests/Unit/Module/Archive/Internal/NullArchiveTest.php b/tests/Unit/Module/Archive/Internal/NullArchiveTest.php index d844a48..ceac09c 100644 --- a/tests/Unit/Module/Archive/Internal/NullArchiveTest.php +++ b/tests/Unit/Module/Archive/Internal/NullArchiveTest.php @@ -47,6 +47,19 @@ public function extractYieldsFileAsItself(): void Assert::same($value, $sourceFile); } + #[Test] + public function entriesReturnsTheFileName(): void + { + $sourceFile = \Mockery::mock(\SplFileInfo::class); + $sourceFile->allows('isFile')->andReturn(true); + $sourceFile->allows('isReadable')->andReturn(true); + $sourceFile->allows('getFilename')->andReturn('source-file'); + + $archive = new NullArchive($sourceFile); + + Assert::same($archive->entries(), ['source-file']); + } + #[Test] public function extractCopiesFileWhenDestinationProvided(): never { diff --git a/tests/Unit/Module/Archive/Stub/TestArchive.php b/tests/Unit/Module/Archive/Stub/TestArchive.php index efd6341..cab736c 100644 --- a/tests/Unit/Module/Archive/Stub/TestArchive.php +++ b/tests/Unit/Module/Archive/Stub/TestArchive.php @@ -44,6 +44,16 @@ public function addFile(string $path, \SplFileInfo $fileInfo): self return $this; } + public function entries(): array + { + if ($this->throwsException) { + throw new ArchiveException($this->exceptionMessage); + } + + /** @var list */ + return \array_keys($this->files); + } + public function extract(): \Generator { if ($this->throwsException) { From 84fb9dbbd6b57a9bf33f90ed06cc9ee05acdd9cb Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 18 Aug 2026 16:44:36 +0400 Subject: [PATCH 9/9] test: cover binary detection and filtering in archive mode The hermetic archive acceptance test used the Windows-only mock zip, so on the Linux CI runner the binary-detection and `` include-filter branches of the archive extraction were never executed, leaving the patch below the coverage target. Add a configurable mock asset (alongside the existing `useMock` test seam) and an acceptance test driven by the nested fixture whose binary has no extension, exercising strip, binary detect/chmod/locate, a matched `` rule and a skipped unmatched entry across platforms. Assisted-By: Claude Opus 4.8 (1M context) --- src/DLoad.php | 29 ++++++++++------- tests/Acceptance/DLoadTest.php | 57 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/DLoad.php b/src/DLoad.php index b05d6be..2d2cd4f 100644 --- a/src/DLoad.php +++ b/src/DLoad.php @@ -51,6 +51,9 @@ final class DLoad /** @var bool Flag to use mock data instead of actual downloads for testing */ public bool $useMock = false; + /** @var \SplFileInfo|null Overrides the asset returned when {@see self::$useMock} is set (tests only) */ + public ?\SplFileInfo $mockArchive = null; + public function __construct( private readonly Logger $logger, private readonly Manager $taskManager, @@ -169,18 +172,20 @@ public function run(): void */ private function prepareDownloadTask(Software $software, DownloadConfig $action): DownloadTask { - return $this->useMock - ? new DownloadTask( - $software, - static fn() => null, - static fn(): PromiseInterface => resolve( - new DownloadResult( - new \SplFileInfo(Info::ROOT_DIR . '/resources/mock/roadrunner-2024.1.5-windows-amd64.zip'), - Version::fromVersionString('2024.1.5'), - ), - ), - ) - : $this->downloader->download($software, $action, static fn() => null); + if (!$this->useMock) { + return $this->downloader->download($software, $action, static fn() => null); + } + + $mockFile = $this->mockArchive + ?? new \SplFileInfo(Info::ROOT_DIR . '/resources/mock/roadrunner-2024.1.5-windows-amd64.zip'); + + return new DownloadTask( + $software, + static fn() => null, + static fn(): PromiseInterface => resolve( + new DownloadResult($mockFile, Version::fromVersionString('2024.1.5')), + ), + ); } /** diff --git a/tests/Acceptance/DLoadTest.php b/tests/Acceptance/DLoadTest.php index c3d0202..4c7c19e 100644 --- a/tests/Acceptance/DLoadTest.php +++ b/tests/Acceptance/DLoadTest.php @@ -167,6 +167,44 @@ public function extractsArchivePreservingStructureAndStrippingTopLevelDir(): voi ); } + #[Test] + public function extractsArchiveWithBinaryAndFileFilterPreservingStructure(): void + { + $dload = $this->buildDLoad($this->createNestedToolXmlConfig()); + $dload->useMock = true; + // A nested layout: pkg-1.0/{bin/app, lib/app/libphp.so, share/app/VERSION.txt} + $dload->mockArchive = new \SplFileInfo( + \dirname(__DIR__) . '/Integration/Module/Archive/Fixture/nested.zip', + ); + + $downloadConfig = new DownloadConfig(); + $downloadConfig->software = 'nested'; + $downloadConfig->type = Type::Archive; + $downloadConfig->extractPath = (string) $this->destinationDir; + + $dload->addTask($downloadConfig); + $dload->run(); + + // The wrapping pkg-1.0/ directory is stripped; the binary and the matched library keep + // their nested layout, while the unmatched VERSION.txt is filtered out by the rules. + Assert::true( + $this->destinationDir->join('bin', 'app')->isFile(), + 'Binary should be extracted keeping its bin/ subdirectory', + ); + Assert::true( + $this->destinationDir->join('lib', 'app', 'libphp.so')->isFile(), + 'File matched by a rule should be extracted keeping its subdirectory', + ); + Assert::false( + $this->destinationDir->join('share', 'app', 'VERSION.txt')->exists(), + 'Files not matched by any rule should be skipped', + ); + Assert::false( + $this->destinationDir->join('pkg-1.0')->exists(), + 'The stripped top-level directory should not be present', + ); + } + #[BeforeTest] protected function prepare(): void { @@ -265,6 +303,25 @@ private function createRoadRunnerXmlConfig(): string XML; } + /** + * @return non-empty-string + */ + private function createNestedToolXmlConfig(): string + { + return << + + + + + + + + + + XML; + } + private function removeDirectory(Path $dir): void { if (!$dir->isDir()) {