Author: Shivam Mathur (shivammathur)
Date: 2026-10-02T19:15:01+05:30
Commit: https://github.com/php/web-downloads/commit/9037edf9156c9d7f0c67a904c23dc00663e16a98
Raw diff: https://github.com/php/web-downloads/commit/9037edf9156c9d7f0c67a904c23dc00663e16a98.diff
Add Winlibs build deletion API and runner support
Changed paths:
A src/Actions/DeleteWinlibsBuild.php
A src/Http/Controllers/WinlibsDeleteController.php
A tests/Console/Command/WinlibsCommandDeleteTest.php
A tests/Http/Controllers/WinlibsDeleteControllerTest.php
M API.md
M routes.php
M src/Console/Command.php
M src/Console/Command/WinlibsCommand.php
M tests/CommandTest.php
Diff:
diff --git a/API.md b/API.md
index 64283b7..8ad1453 100644
--- a/API.md
+++ b/API.md
@@ -30,6 +30,7 @@
- [POST /api/php](#post-apiphp)
- [POST /api/pecl](#post-apipecl)
- [POST /api/winlibs](#post-apiwinlibs)
+- [POST /api/winlibs-delete](#post-apiwinlibs-delete)
- [POST /api/sbom-update](#post-apisbom-update)
- [POST /api/series-init](#post-apiseries-init)
- [POST /api/series-delete](#post-apiseries-delete)
@@ -186,6 +187,32 @@ curl -i -X POST \
---
+### POST /api/winlibs-delete
+
+- Auth: Required
+- Purpose: Queue deletion of one published Winlibs ZIP and its package references. The existing
winlibs:add runner processes deletion jobs after uploads. winlibs:add
--delete processes only deletion jobs when run manually.
+- Request body (JSON):
+ - type (string, required): php or pecl.
+ - filename (string, required): Exact ZIP basename, such as
libcurl-8.22.0-1-vs18-x64.zip. Paths and non-ZIP names are rejected.
+- For php, the processor removes exact matching lines from every
php-sdk/deps/series/packages-*.txt file and deletes every copy of that filename under
php-sdk/deps/<VS>/<arch>/. This covers builds copied to multiple VS
targets. An empty series file is removed.
+- For pecl, the processor removes the exact line from
pecl/deps/packages.txt and deletes pecl/deps/<filename>. It leaves
every unrelated index entry unchanged.
+- Success: 200 OK, empty body, meaning the deletion was queued. Repeating a deletion
is safe.
+- Errors:
+ - 400 with validation details if the payload is invalid.
+ - 500 if BUILDS_DIRECTORY is not configured or the job cannot be
queued.
+
+Example
+
+```bash
+curl -i -X POST \
+ -H "Authorization: Bearer $AUTH_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d
'{"type":"php","filename":"libcurl-8.22.0-1-vs18-x64.zip"}'
\
+ https://downloads.php.net/api/winlibs-delete
+```
+
+---
+
### POST /api/series-init
- Auth: Required
diff --git a/routes.php b/routes.php
index 61189f7..1ccb6a5 100644
--- a/routes.php
+++ b/routes.php
@@ -12,6 +12,7 @@
use App\Http\Controllers\SeriesStabilityController;
use App\Http\Controllers\SeriesUpdateController;
use App\Http\Controllers\WinlibsController;
+use App\Http\Controllers\WinlibsDeleteController;
use App\Router;
$router = new Router();
@@ -20,6 +21,7 @@
$router->registerRoute('/api/delete-pending-job', 'POST',
DeletePendingJobController::class, true);
$router->registerRoute('/api/pecl', 'POST', PeclController::class, true);
$router->registerRoute('/api/winlibs', 'POST', WinlibsController::class,
true);
+$router->registerRoute('/api/winlibs-delete', 'POST',
WinlibsDeleteController::class, true);
$router->registerRoute('/api/php', 'POST', PhpController::class, true);
$router->registerRoute('/api/sbom-update', 'POST',
SbomUpdateController::class, true);
$router->registerRoute('/api/series-init', 'POST',
SeriesInitController::class, true);
diff --git a/src/Actions/DeleteWinlibsBuild.php b/src/Actions/DeleteWinlibsBuild.php
new file mode 100644
index 0000000..a117899
--- /dev/null
+++ b/src/Actions/DeleteWinlibsBuild.php
@@ -0,0 +1,174 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Actions;
+
+use Exception;
+
+class DeleteWinlibsBuild
+{
+ public function __construct(
+ private readonly string $baseDirectory,
+ private readonly string $buildsDirectory
+ ) {
+ }
+
+ public function handle(): void
+ {
+ $queueDirectory = rtrim($this->buildsDirectory, '/') .
'/winlibs-delete';
+ if (!is_dir($queueDirectory)) {
+ return;
+ }
+
+ $tasks = glob($queueDirectory . '/winlibs-delete-*.json');
+ if ($tasks === false) {
+ throw new Exception("Unable to list Winlibs deletion queue:
$queueDirectory");
+ }
+ foreach ($tasks as $taskFile) {
+ $lockPath = $taskFile . '.lock';
+ $lock = fopen($lockPath, 'c');
+ if ($lock === false) {
+ throw new Exception("Unable to open lock file: $lockPath");
+ }
+
+ try {
+ if (!flock($lock, LOCK_EX | LOCK_NB)) {
+ continue;
+ }
+ if (!is_file($taskFile)) {
+ continue;
+ }
+
+ $data = json_decode((string) file_get_contents($taskFile), true, 512,
JSON_THROW_ON_ERROR);
+ $type = $data['type'] ?? null;
+ $filename = $data['filename'] ?? null;
+ if (!in_array($type, ['php', 'pecl'], true)
+ || !is_string($filename)
+ || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*\.zip$/', $filename) !==
1) {
+ throw new Exception("Invalid Winlibs deletion task: $taskFile");
+ }
+
+ if ($type === 'php') {
+ $this->deletePhpBuild(rtrim($this->baseDirectory, '/'),
$filename);
+ } else {
+ $this->deletePeclBuild(rtrim($this->baseDirectory, '/'),
$filename);
+ }
+
+ if (!unlink($taskFile)) {
+ throw new Exception("Unable to remove task: $taskFile");
+ }
+ } finally {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ if (!is_file($taskFile)) {
+ @unlink($lockPath);
+ }
+ }
+ }
+ }
+
+ private function deletePhpBuild(string $baseDirectory, string $filename): void
+ {
+ $depsDirectory = $baseDirectory . '/php-sdk/deps';
+ $seriesDirectory = $depsDirectory . '/series';
+
+ $seriesFiles = glob($seriesDirectory . '/packages-*.txt');
+ if ($seriesFiles === false) {
+ throw new Exception("Unable to list package indexes: $seriesDirectory");
+ }
+ foreach ($seriesFiles as $seriesFile) {
+ $this->removeIndexEntry($seriesFile, $filename, true);
+ }
+
+ $vsDirectories = glob($depsDirectory . '/v[cs][0-9][0-9]', GLOB_ONLYDIR);
+ if ($vsDirectories === false) {
+ throw new Exception("Unable to list VS directories: $depsDirectory");
+ }
+ foreach ($vsDirectories as $vsDirectory) {
+ if (is_link($vsDirectory)) {
+ throw new Exception("Invalid VS directory: $vsDirectory");
+ }
+ foreach (['x86', 'x64', 'arm64'] as $arch) {
+ if (is_link($vsDirectory . '/' . $arch)) {
+ throw new Exception("Invalid architecture directory:
$vsDirectory/$arch");
+ }
+ $this->deleteFile($vsDirectory . '/' . $arch . '/' .
$filename);
+ }
+ }
+ }
+
+ private function deletePeclBuild(string $baseDirectory, string $filename): void
+ {
+ $depsDirectory = $baseDirectory . '/pecl/deps';
+ $this->removeIndexEntry($depsDirectory . '/packages.txt', $filename, false);
+ $this->deleteFile($depsDirectory . '/' . $filename);
+ }
+
+ private function removeIndexEntry(string $path, string $filename, bool $removeEmptyFile): void
+ {
+ if (!file_exists($path)) {
+ return;
+ }
+ if (!is_file($path) || is_link($path)) {
+ throw new Exception("Invalid package index: $path");
+ }
+
+ $contents = file_get_contents($path);
+ if ($contents === false) {
+ throw new Exception("Unable to read package index: $path");
+ }
+
+ $newline = str_contains($contents, "\r\n") ? "\r\n" : "\n";
+ $trailingNewline = str_ends_with($contents, "\n");
+ $lines = $contents === '' ? [] : preg_split('/\r\n|\n|\r/', $contents);
+ if ($trailingNewline) {
+ array_pop($lines);
+ }
+
+ $remaining = array_values(array_filter($lines, static fn (string $line): bool => $line
!== $filename));
+ if (count($remaining) === count($lines)) {
+ return;
+ }
+
+ if ($remaining === [] && $removeEmptyFile) {
+ if (!unlink($path)) {
+ throw new Exception("Unable to remove package index: $path");
+ }
+ return;
+ }
+
+ $updated = implode($newline, $remaining);
+ if ($trailingNewline && $remaining !== []) {
+ $updated .= $newline;
+ }
+
+ $temporary = @tempnam(dirname($path), '.packages-');
+ if ($temporary === false || realpath(dirname($temporary)) !== realpath(dirname($path))) {
+ if ($temporary !== false) {
+ unlink($temporary);
+ }
+ throw new Exception("Unable to create temporary index: $path");
+ }
+ try {
+ $permissions = fileperms($path);
+ if ($permissions === false || file_put_contents($temporary, $updated, LOCK_EX) ===
false
+ || !chmod($temporary, $permissions & 0777) || !rename($temporary, $path)) {
+ throw new Exception("Unable to update package index: $path");
+ }
+ } finally {
+ if (is_file($temporary)) {
+ unlink($temporary);
+ }
+ }
+ }
+
+ private function deleteFile(string $path): void
+ {
+ if (!file_exists($path) && !is_link($path)) {
+ return;
+ }
+ if (!is_file($path) || is_link($path) || !unlink($path)) {
+ throw new Exception("Unable to delete Winlibs file: $path");
+ }
+ }
+}
diff --git a/src/Console/Command.php b/src/Console/Command.php
index 3f49531..0a98c54 100644
--- a/src/Console/Command.php
+++ b/src/Console/Command.php
@@ -43,6 +43,8 @@ private function parse(int $argc, array $argv): void {
for ($i = 1; $i < $argc; $i++) {
if (preg_match('/^--([^=]+)=(.*)$/', (string) $argv[$i], $matches)) {
$this->options[$matches[1]] = $matches[2];
+ } elseif (preg_match('/^--([A-Za-z][A-Za-z0-9-]*)$/', (string) $argv[$i],
$matches)) {
+ $this->options[$matches[1]] = true;
} else {
if (isset($signatureParts[$argCount])) {
$this->arguments[$signatureParts[$argCount]] = $argv[$i];
diff --git a/src/Console/Command/WinlibsCommand.php b/src/Console/Command/WinlibsCommand.php
index 4bdb586..bfe6b17 100644
--- a/src/Console/Command/WinlibsCommand.php
+++ b/src/Console/Command/WinlibsCommand.php
@@ -3,6 +3,7 @@
namespace App\Console\Command;
+use App\Actions\DeleteWinlibsBuild;
use App\Console\Command;
use App\Helpers\Helpers;
use Exception;
@@ -10,8 +11,8 @@
class WinlibsCommand extends Command
{
- public string $signature = 'winlibs:add --base-directory= --builds-directory=';
- public string $description = 'Add winlibs dependencies';
+ public string $signature = 'winlibs:add --base-directory= --builds-directory=
--delete';
+ public string $description = 'Add winlibs dependencies and process queued deletions';
protected ?string $baseDirectory = null;
@@ -28,6 +29,12 @@ public function handle(): int
throw new Exception('Build directory is required');
}
+ $deletions = new DeleteWinlibsBuild($this->baseDirectory, $buildsDirectory);
+ if (($this->options['delete'] ?? false) === true) {
+ $deletions->handle();
+ return Command::SUCCESS;
+ }
+
$buildDirectories = glob($buildsDirectory . '/winlibs/*', GLOB_ONLYDIR);
// We lock the Directories we are working on
@@ -69,6 +76,11 @@ public function handle(): int
unlink($directoryPath . '.lock');
}
+
+ // Process deletions after uploads so a queued upload cannot restore
+ // a build that was also queued for deletion.
+ $deletions->handle();
+
return Command::SUCCESS;
} catch (Exception $e) {
echo $e->getMessage();
diff --git a/src/Http/Controllers/WinlibsDeleteController.php
b/src/Http/Controllers/WinlibsDeleteController.php
new file mode 100644
index 0000000..269bf33
--- /dev/null
+++ b/src/Http/Controllers/WinlibsDeleteController.php
@@ -0,0 +1,61 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\BaseController;
+use App\Validator;
+
+class WinlibsDeleteController extends BaseController
+{
+ protected function validate(array $data): bool
+ {
+ $validator = new Validator([
+ 'type' => 'required|string|regex:/^(php|pecl)$/',
+ 'filename' =>
'required|string|regex:/^[A-Za-z0-9][A-Za-z0-9._-]*\.zip$/',
+ ]);
+
+ $validator->validate($data);
+ if (!$validator->isValid) {
+ http_response_code(400);
+ echo 'Invalid request: ' . $validator;
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function execute(array $data): void
+ {
+ $buildsDirectory = rtrim((string) getenv('BUILDS_DIRECTORY'), '/');
+ if ($buildsDirectory === '') {
+ http_response_code(500);
+ echo 'Invalid server configuration: BUILDS_DIRECTORY is not set.';
+ return;
+ }
+
+ $queueDirectory = $buildsDirectory . '/winlibs-delete';
+ if (!is_dir($queueDirectory) && !mkdir($queueDirectory, 0755, true) &&
!is_dir($queueDirectory)) {
+ http_response_code(500);
+ echo 'Unable to create Winlibs deletion queue.';
+ return;
+ }
+
+ $taskFile = @tempnam($queueDirectory, 'winlibs-delete-');
+ $payload = json_encode([
+ 'type' => $data['type'],
+ 'filename' => $data['filename'],
+ ], JSON_THROW_ON_ERROR);
+ if ($taskFile === false || realpath(dirname($taskFile)) !== realpath($queueDirectory)
+ || file_put_contents($taskFile, $payload, LOCK_EX) === false
+ || !chmod($taskFile, 0644)
+ || !rename($taskFile, $taskFile . '.json')) {
+ if ($taskFile !== false) {
+ unlink($taskFile);
+ }
+ http_response_code(500);
+ echo 'Unable to queue Winlibs deletion.';
+ return;
+ }
+ }
+}
diff --git a/tests/CommandTest.php b/tests/CommandTest.php
index c9b9dcf..6fd93fa 100644
--- a/tests/CommandTest.php
+++ b/tests/CommandTest.php
@@ -4,7 +4,7 @@
use App\Console\Command;
class TestCommand extends Command {
- public string $signature = "test {arg} {--option=}";
+ public string $signature = "test {arg} {--option=} {--delete}";
public function handle(): int {
return Command::SUCCESS;
@@ -13,12 +13,13 @@ public function handle(): int {
class CommandTest extends TestCase {
public function testParseArgumentsAndOptions() {
- $argv = ["script.php", "value", "--option=optValue"];
+ $argv = ["script.php", "value", "--option=optValue",
"--delete"];
$command = new TestCommand();
$command->cliArguments = $argv;
$this->assertEquals("value", $command->arguments["arg"] ?? null,
"Argument parsing failed.");
$this->assertEquals("optValue", $command->options["option"] ??
null, "Option parsing failed.");
+ $this->assertTrue($command->options['delete'] ?? false, 'Boolean
option parsing failed.');
$command->options = ['option' => "newOptValue"];
$this->assertEquals("newOptValue", $command->options["option"] ??
null, "Option setting failed.");
diff --git a/tests/Console/Command/WinlibsCommandDeleteTest.php
b/tests/Console/Command/WinlibsCommandDeleteTest.php
new file mode 100644
index 0000000..b31bd62
--- /dev/null
+++ b/tests/Console/Command/WinlibsCommandDeleteTest.php
@@ -0,0 +1,324 @@
+<?php
+declare(strict_types=1);
+
+namespace Console\Command;
+
+use App\Console\Command\WinlibsCommand;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\TestCase;
+
+class WinlibsCommandDeleteTest extends TestCase
+{
+ private string $baseDirectory;
+ private string $buildsDirectory;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->baseDirectory = sys_get_temp_dir() . '/winlibs_delete_base_' .
uniqid();
+ $this->buildsDirectory = sys_get_temp_dir() . '/winlibs_delete_builds_' .
uniqid();
+ mkdir($this->baseDirectory, 0755, true);
+ mkdir($this->buildsDirectory, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ Helpers::rmdirr($this->baseDirectory);
+ Helpers::rmdirr($this->buildsDirectory);
+ parent::tearDown();
+ }
+
+ public function testPhpDeletionRemovesExactBuildFromEverySeriesAndArtifactDirectory(): void
+ {
+ $target = 'libcurl-8.22.0-1-vs18-x64.zip';
+ $older = 'libcurl-8.22.0-vs18-x64.zip';
+ $other = 'libcurl-ng-8.22.0-1-vs18-x64.zip';
+ $series = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($series, 0755, true);
+ $stable = "$series/packages-8.6-vs18-x64-stable.txt";
+ $staging = "$series/packages-8.7-vs18-x64-staging.txt";
+ $master = "$series/packages-master-vs18-x64-stable.txt";
+ $unrelated = "$series/packages-8.6-vs18-x86-stable.txt";
+ file_put_contents($stable, "$older\n$target\n$other\n");
+ file_put_contents($staging, "$target\n$older");
+ file_put_contents($master, "$target");
+ file_put_contents($unrelated, 'libcurl-8.22.0-1-vs18-x86.zip');
+ chmod($stable, 0644);
+
+ foreach (['vs18/x64', 'vs17/x64'] as $targetDirectory) {
+ $directory = $this->baseDirectory . '/php-sdk/deps/' . $targetDirectory;
+ mkdir($directory, 0755, true);
+ file_put_contents($directory . '/' . $target, 'target');
+ file_put_contents($directory . '/' . $older, 'older');
+ }
+ $this->queue('php', $target);
+
+ $this->assertSame(0, $this->runCommand());
+ $this->assertSame("$older\n$other\n", file_get_contents($stable));
+ $this->assertSame($older, file_get_contents($staging));
+ $this->assertFileDoesNotExist($master);
+ $this->assertSame('libcurl-8.22.0-1-vs18-x86.zip',
file_get_contents($unrelated));
+ $this->assertSame(0644, fileperms($stable) & 0777);
+ foreach (['vs18/x64', 'vs17/x64'] as $targetDirectory) {
+ $directory = $this->baseDirectory . '/php-sdk/deps/' . $targetDirectory;
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ $this->assertFileExists($directory . '/' . $older);
+ }
+ $this->assertSame([], $this->queuedTasks());
+ }
+
+ public function testPeclDeletionOnlyRemovesExactPackageLineAndZip(): void
+ {
+ $directory = $this->baseDirectory . '/pecl/deps';
+ mkdir($directory, 0755, true);
+ $target = 'OpenBLAS-0.3.34-vs18-x64.zip';
+ $listed = 'OpenBLAS-0.3.18-vs16-x64.zip';
+ $unlisted = 'OpenBLAS-0.3.34-vs18-x86.zip';
+ foreach ([$target, $listed, $unlisted] as $filename) {
+ file_put_contents($directory . '/' . $filename, 'zip');
+ }
+ file_put_contents($directory . '/packages.txt',
"$listed\r\n$target\r\n");
+ $this->queue('pecl', $target);
+
+ $this->assertSame(0, $this->runCommand());
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ $this->assertFileExists($directory . '/' . $listed);
+ $this->assertFileExists($directory . '/' . $unlisted);
+ $this->assertSame("$listed\r\n", file_get_contents($directory .
'/packages.txt'));
+ $this->assertSame([], $this->queuedTasks());
+ }
+
+ public function testPeclDeletionDoesNotAddIntentionallyUnlistedZip(): void
+ {
+ $directory = $this->baseDirectory . '/pecl/deps';
+ mkdir($directory, 0755, true);
+ $target = 'OpenBLAS-0.3.34-vs18-x64.zip';
+ file_put_contents($directory . '/' . $target, 'zip');
+ file_put_contents($directory . '/packages.txt',
"listed-1.0-vs18-x64.zip\n");
+ $this->queue('pecl', $target);
+
+ $this->assertSame(0, $this->runCommand());
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ $this->assertSame("listed-1.0-vs18-x64.zip\n", file_get_contents($directory .
'/packages.txt'));
+ }
+
+ public function testPeclDeletionWorksWithoutPackagesIndex(): void
+ {
+ $directory = $this->baseDirectory . '/pecl/deps';
+ mkdir($directory, 0755, true);
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ file_put_contents($directory . '/' . $target, 'zip');
+ $this->queue('pecl', $target);
+
+ $this->assertSame(0, $this->runCommand());
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ $this->assertFileDoesNotExist($directory . '/packages.txt');
+ }
+
+ public function testPhpDeletionCleansStaleSeriesEntryWithoutZip(): void
+ {
+ $directory = $this->baseDirectory . '/php-sdk/deps/series';
+ mkdir($directory, 0755, true);
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ $index = $directory . '/packages-8.6-vs18-x64-stable.txt';
+ file_put_contents($index, "other-1.0-vs18-x64.zip\n$target");
+ $this->queue('php', $target);
+
+ $this->assertSame(0, $this->runCommand());
+ $this->assertSame('other-1.0-vs18-x64.zip', file_get_contents($index));
+ }
+
+ public function testMissingBuildAndIndexEntriesAreIdempotent(): void
+ {
+ $this->queue('php', 'missing-1.0-vs18-x64.zip');
+ $this->queue('pecl', 'missing-1.0-vs18-x64.zip');
+ $this->assertSame(0, $this->runCommand());
+ $this->assertSame([], $this->queuedTasks());
+ }
+
+ public function testInvalidTaskFailsAndRemainsQueued(): void
+ {
+ $task = $this->queue('php', '../outside.zip');
+ ob_start();
+ $result = $this->runCommand();
+ $output = (string) ob_get_clean();
+ $this->assertSame(1, $result);
+ $this->assertStringContainsString('Invalid Winlibs deletion task', $output);
+ $this->assertFileExists($task);
+ }
+
+ public function testIndexFailureRetainsTaskAndZipForRetry(): void
+ {
+ $directory = $this->baseDirectory . '/pecl/deps';
+ mkdir($directory . '/packages.txt', 0755, true);
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ file_put_contents($directory . '/' . $target, 'zip');
+ $task = $this->queue('pecl', $target);
+
+ ob_start();
+ $result = $this->runCommand();
+ $output = (string) ob_get_clean();
+ $this->assertSame(1, $result);
+ $this->assertStringContainsString('Invalid package index', $output);
+ $this->assertFileExists($task);
+ $this->assertFileExists($directory . '/' . $target);
+
+ rmdir($directory . '/packages.txt');
+ $this->assertSame(0, $this->runCommand());
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ $this->assertSame([], $this->queuedTasks());
+ }
+
+ public function testPartialSeriesUpdateCanBeRetriedWithoutDeletingZipEarly(): void
+ {
+ $series = $this->baseDirectory . '/php-sdk/deps/series';
+ $zipDirectory = $this->baseDirectory . '/php-sdk/deps/vs18/x64';
+ mkdir($series, 0755, true);
+ mkdir($zipDirectory, 0755, true);
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ $first = $series . '/packages-8.6-vs18-x64-stable.txt';
+ $second = $series . '/packages-8.7-vs18-x64-stable.txt';
+ file_put_contents($first, "other-1.0-vs18-x64.zip\n$target");
+ mkdir($second, 0755, true);
+ file_put_contents($zipDirectory . '/' . $target, 'zip');
+ $task = $this->queue('php', $target);
+
+ ob_start();
+ $this->assertSame(1, $this->runCommand());
+ ob_end_clean();
+ $this->assertSame('other-1.0-vs18-x64.zip', file_get_contents($first));
+ $this->assertFileExists($zipDirectory . '/' . $target);
+ $this->assertFileExists($task);
+
+ rmdir($second);
+ file_put_contents($second, $target);
+ $this->assertSame(0, $this->runCommand());
+ $this->assertFileDoesNotExist($second);
+ $this->assertFileDoesNotExist($zipDirectory . '/' . $target);
+ $this->assertSame([], $this->queuedTasks());
+ }
+
+ public function testLockedJobIsProcessedOnNextRun(): void
+ {
+ $directory = $this->baseDirectory . '/pecl/deps';
+ mkdir($directory, 0755, true);
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ file_put_contents($directory . '/' . $target, 'zip');
+ $task = $this->queue('pecl', $target);
+ $lock = fopen($task . '.lock', 'c');
+ flock($lock, LOCK_EX);
+ try {
+ $this->assertSame(0, $this->runCommand());
+ $this->assertFileExists($task);
+ $this->assertFileExists($directory . '/' . $target);
+ } finally {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ }
+ $this->assertSame(0, $this->runCommand());
+ $this->assertFileDoesNotExist($task);
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ }
+
+ public function testExistingWinlibsRunnerProcessesDeleteQueue(): void
+ {
+ $directory = $this->baseDirectory . '/pecl/deps';
+ mkdir($directory, 0755, true);
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ file_put_contents($directory . '/' . $target, 'zip');
+ file_put_contents($directory . '/packages.txt', $target);
+ $this->queue('pecl', $target);
+
+ $command = new WinlibsCommand();
+ $command->options = ['base-directory' => $this->baseDirectory,
'builds-directory' => $this->buildsDirectory];
+ $this->assertSame(0, $command->handle());
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ $this->assertSame('', file_get_contents($directory .
'/packages.txt'));
+ $this->assertSame([], $this->queuedTasks());
+ }
+
+ public function testDeleteFlagSkipsPendingUploads(): void
+ {
+ $directory = $this->baseDirectory . '/pecl/deps';
+ mkdir($directory, 0755, true);
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ file_put_contents($directory . '/' . $target, 'zip');
+ $this->queue('pecl', $target);
+ $pendingUpload = $this->buildsDirectory . '/winlibs/pending';
+ mkdir($pendingUpload, 0755, true);
+
+ $command = new WinlibsCommand();
+ $command->cliArguments = [
+ 'runner.php',
+ 'winlibs:add',
+ '--base-directory=' . $this->baseDirectory,
+ '--builds-directory=' . $this->buildsDirectory,
+ '--delete',
+ ];
+ $this->assertSame(0, $command->handle());
+ $this->assertFileDoesNotExist($directory . '/' . $target);
+ $this->assertDirectoryExists($pendingUpload);
+ }
+
+ public function testDefaultRunnerDeletesBuildUploadedInSameRun(): void
+ {
+ $target = 'libfoo-1.0-vs18-x64.zip';
+ $pendingUpload = $this->buildsDirectory . '/winlibs/1234';
+ mkdir($pendingUpload, 0755, true);
+ file_put_contents($pendingUpload . '/data.json', json_encode([
+ 'type' => 'pecl',
+ 'library' => 'libfoo',
+ ], JSON_THROW_ON_ERROR));
+ file_put_contents($pendingUpload . '/' . $target, 'zip');
+ $this->queue('pecl', $target);
+
+ $command = new WinlibsCommand();
+ $command->options = ['base-directory' => $this->baseDirectory,
'builds-directory' => $this->buildsDirectory];
+ $this->assertSame(0, $command->handle());
+ $this->assertFileDoesNotExist($this->baseDirectory . '/pecl/deps/' .
$target);
+ $this->assertSame('', file_get_contents($this->baseDirectory .
'/pecl/deps/packages.txt'));
+ $this->assertDirectoryDoesNotExist($pendingUpload);
+ $this->assertSame([], $this->queuedTasks());
+ }
+
+ public function testRequiresBothDirectories(): void
+ {
+ $command = new WinlibsCommand();
+ $command->options = ['builds-directory' => $this->buildsDirectory];
+ ob_start();
+ $this->assertSame(1, $command->handle());
+ $this->assertSame('Base directory is required', ob_get_clean());
+
+ $command->options = ['base-directory' => $this->baseDirectory];
+ ob_start();
+ $this->assertSame(1, $command->handle());
+ $this->assertSame('Build directory is required', ob_get_clean());
+ }
+
+ private function queue(string $type, string $filename): string
+ {
+ $queue = $this->buildsDirectory . '/winlibs-delete';
+ if (!is_dir($queue)) {
+ mkdir($queue, 0755, true);
+ }
+ $path = $queue . '/winlibs-delete-' . uniqid() . '.json';
+ file_put_contents($path, json_encode(['type' => $type, 'filename'
=> $filename], JSON_THROW_ON_ERROR));
+ return $path;
+ }
+
+ private function queuedTasks(): array
+ {
+ return glob($this->buildsDirectory . '/winlibs-delete/*.json') ?: [];
+ }
+
+ private function runCommand(): int
+ {
+ $command = new WinlibsCommand();
+ $command->options = [
+ 'base-directory' => $this->baseDirectory,
+ 'builds-directory' => $this->buildsDirectory,
+ 'delete' => true,
+ ];
+ return $command->handle();
+ }
+}
diff --git a/tests/Http/Controllers/WinlibsDeleteControllerTest.php
b/tests/Http/Controllers/WinlibsDeleteControllerTest.php
new file mode 100644
index 0000000..8c9a9f5
--- /dev/null
+++ b/tests/Http/Controllers/WinlibsDeleteControllerTest.php
@@ -0,0 +1,122 @@
+<?php
+declare(strict_types=1);
+
+namespace Http\Controllers;
+
+use App\Console\Command\WinlibsCommand;
+use App\Helpers\Helpers;
+use App\Http\Controllers\WinlibsDeleteController;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class WinlibsDeleteControllerTest extends TestCase
+{
+ private string $buildsDirectory;
+ private string|false $originalBuildsDirectory;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->buildsDirectory = sys_get_temp_dir() . '/winlibs_delete_controller_' .
uniqid();
+ mkdir($this->buildsDirectory, 0755, true);
+ $this->originalBuildsDirectory = getenv('BUILDS_DIRECTORY');
+ putenv('BUILDS_DIRECTORY=' . $this->buildsDirectory);
+ http_response_code(200);
+ }
+
+ protected function tearDown(): void
+ {
+ putenv($this->originalBuildsDirectory === false
+ ? 'BUILDS_DIRECTORY'
+ : 'BUILDS_DIRECTORY=' . $this->originalBuildsDirectory);
+ Helpers::rmdirr($this->buildsDirectory);
+ http_response_code(200);
+ parent::tearDown();
+ }
+
+ public function testQueuesExactBuildForBothTypes(): void
+ {
+ $this->request(['type' => 'php', 'filename' =>
'libcurl-8.22.0-1-vs18-x64.zip']);
+ $this->request(['type' => 'pecl', 'filename' =>
'OpenBLAS-0.3.34-vs18-x86.zip']);
+
+ $tasks = glob($this->buildsDirectory .
'/winlibs-delete/winlibs-delete-*.json');
+ $this->assertCount(2, $tasks);
+ $payloads = array_map(static fn (string $path): array => json_decode(
+ (string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR
+ ), $tasks);
+ $this->assertContains(['type' => 'php', 'filename'
=> 'libcurl-8.22.0-1-vs18-x64.zip'], $payloads);
+ $this->assertContains(['type' => 'pecl', 'filename'
=> 'OpenBLAS-0.3.34-vs18-x86.zip'], $payloads);
+ $this->assertSame(200, http_response_code());
+ }
+
+ #[DataProvider('invalidPayloads')]
+ public function testRejectsInvalidPayload(array $payload): void
+ {
+ $output = $this->request($payload);
+ $this->assertSame(400, http_response_code());
+ $this->assertStringContainsString('Invalid request:', $output);
+ $this->assertSame([], glob($this->buildsDirectory .
'/winlibs-delete/*.json') ?: []);
+ }
+
+ public static function invalidPayloads(): array
+ {
+ return [
+ 'missing type' => [['filename' =>
'zlib-1.3.2-vs18-x64.zip']],
+ 'bad type' => [['type' => 'other',
'filename' => 'zlib-1.3.2-vs18-x64.zip']],
+ 'missing filename' => [['type' => 'php']],
+ 'path traversal' => [['type' => 'php',
'filename' => '../zlib.zip']],
+ 'nested path' => [['type' => 'php',
'filename' => 'vs18/x64/zlib.zip']],
+ 'non zip' => [['type' => 'pecl',
'filename' => 'packages.txt']],
+ 'empty filename' => [['type' => 'pecl',
'filename' => '']],
+ ];
+ }
+
+ public function testRejectsMissingBuildsDirectoryConfiguration(): void
+ {
+ putenv('BUILDS_DIRECTORY');
+ $output = $this->request(['type' => 'php', 'filename'
=> 'zlib-1.3.2-vs18-x64.zip']);
+ $this->assertSame(500, http_response_code());
+ $this->assertStringContainsString('BUILDS_DIRECTORY is not set', $output);
+ }
+
+ public function testQueuedRequestIsAppliedByExistingWinlibsRunner(): void
+ {
+ $baseDirectory = sys_get_temp_dir() . '/winlibs_delete_api_base_' . uniqid();
+ $depsDirectory = $baseDirectory . '/php-sdk/deps';
+ mkdir($depsDirectory . '/series', 0755, true);
+ mkdir($depsDirectory . '/vs18/x64', 0755, true);
+ $target = 'libcurl-8.22.0-1-vs18-x64.zip';
+ $older = 'libcurl-8.22.0-vs18-x64.zip';
+ $index = $depsDirectory . '/series/packages-8.6-vs18-x64-stable.txt';
+ file_put_contents($index, "$older\n$target");
+ file_put_contents($depsDirectory . '/vs18/x64/' . $target, 'zip');
+
+ try {
+ $this->request(['type' => 'php', 'filename' =>
$target]);
+ $command = new WinlibsCommand();
+ $command->options = [
+ 'base-directory' => $baseDirectory,
+ 'builds-directory' => $this->buildsDirectory,
+ ];
+ $this->assertSame(0, $command->handle());
+ $this->assertSame($older, file_get_contents($index));
+ $this->assertFileDoesNotExist($depsDirectory . '/vs18/x64/' . $target);
+ $this->assertSame([], glob($this->buildsDirectory .
'/winlibs-delete/*.json') ?: []);
+ } finally {
+ Helpers::rmdirr($baseDirectory);
+ }
+ }
+
+ private function request(array $payload): string
+ {
+ $input = tempnam(sys_get_temp_dir(), 'winlibs-delete-input-');
+ file_put_contents($input, json_encode($payload, JSON_THROW_ON_ERROR));
+ try {
+ ob_start();
+ (new WinlibsDeleteController($input))->handle();
+ return (string) ob_get_clean();
+ } finally {
+ unlink($input);
+ }
+ }
+}