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/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", 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/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": { 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. diff --git a/src/DLoad.php b/src/DLoad.php index 96218c8..2d2cd4f 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; @@ -50,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, @@ -168,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')), + ), + ); } /** @@ -218,6 +224,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 +349,131 @@ 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()); + + # 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); + $hasFilters = $software->files !== []; + + $resultFiles = []; + $resultBinary = null; + + # 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..d6706d6 100644 --- a/src/Module/Archive/Archive.php +++ b/src/Module/Archive/Archive.php @@ -17,22 +17,38 @@ 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; + + /** + * 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/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..0d41be3 100644 --- a/src/Module/Archive/Internal/GzArchive.php +++ b/src/Module/Archive/Internal/GzArchive.php @@ -29,9 +29,7 @@ public function extract(): \Generator ); try { - // Derive output filename by stripping .gz extension - $fileName = $this->asset->getFilename(); - $outputName = \preg_replace('/\.gz$/i', '', $fileName) ?? $fileName; + $outputName = $this->outputName(); $tempPath = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . $outputName; $out = \fopen($tempPath, 'wb'); @@ -53,8 +51,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()); @@ -63,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 3b2dfda..e13b42d 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(); @@ -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 5d95a33..aedb569 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,17 +55,45 @@ 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 instanceof \SplFileInfo and \copy( - $file->getPathname(), - $fileTo->getRealPath() ?: $fileTo->getPathname(), - ); + $fileTo = yield $relativePath => $file; + + 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); + } } } + 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/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..4c7c19e 100644 --- a/tests/Acceptance/DLoadTest.php +++ b/tests/Acceptance/DLoadTest.php @@ -136,6 +136,75 @@ 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(); + + // 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'); + + // 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', + ); + } + + #[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 { @@ -214,6 +283,45 @@ private function createTrapXmlConfig(): string } + /** + * @return non-empty-string + */ + private function createRoadRunnerXmlConfig(): string + { + return << + + + + + + + + + XML; + } + + /** + * @return non-empty-string + */ + private function createNestedToolXmlConfig(): 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..2853ca8 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,56 @@ public function factoryExtendWithCustomImplementation(): void Assert::same($archive, $customArchive); } + #[DataProvider('provideNestedArchiveTypes')] + #[Test] + public function extractKeysEntriesByTheirArchiveRelativePath(string $type): void + { + $archive = $this->factory->create(new \SplFileInfo($this->nestedArchiveFixture($type))); + + $keys = []; + foreach ($archive->extract() as $relativePath => $_) { + $keys[] = $relativePath; + } + + \sort($keys); + 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 + { + $archive = $this->factory->create(new \SplFileInfo($this->nestedArchiveFixture($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 +173,21 @@ protected function cleanup(): void } } + /** + * 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 fixture archive + */ + private function nestedArchiveFixture(string $type): string + { + return __DIR__ . '/Fixture/nested.' . $type; + } + /** * Recursively remove a directory and its contents */ 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 0000000..e0a680d Binary files /dev/null and b/tests/Integration/Module/Archive/Fixture/nested.tar.gz differ diff --git a/tests/Integration/Module/Archive/Fixture/nested.zip b/tests/Integration/Module/Archive/Fixture/nested.zip new file mode 100644 index 0000000..083c6f6 Binary files /dev/null and b/tests/Integration/Module/Archive/Fixture/nested.zip differ 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/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 0d23718..4eddc71 100644 --- a/tests/Unit/Module/Archive/Internal/GzArchiveTest.php +++ b/tests/Unit/Module/Archive/Internal/GzArchiveTest.php @@ -40,13 +40,22 @@ 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'); + } + + #[Test] + public function entriesReturnsTheDecompressedNameWithoutTouchingTheStream(): void + { + $archive = $this->gzArchive('payload.txt.gz', 'compressed content'); + + Assert::same($archive->entries(), ['payload.txt']); } #[DataProvider('provideArchiveNames')] diff --git a/tests/Unit/Module/Archive/Internal/NullArchiveTest.php b/tests/Unit/Module/Archive/Internal/NullArchiveTest.php index 7458b87..ceac09c 100644 --- a/tests/Unit/Module/Archive/Internal/NullArchiveTest.php +++ b/tests/Unit/Module/Archive/Internal/NullArchiveTest.php @@ -43,10 +43,23 @@ 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); } + #[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) {