[web-downloads] main: Add Winlibs build deletion API and runner support

From: Date: Fri, 02 Oct 2026 14:49:03 +0000
Subject: [web-downloads] main: Add Winlibs build deletion API and runner support
Groups: php.webmaster 
Request: Send a blank email to php-webmaster+get-34063@lists.php.net to get a copy of this message
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);
+        }
+    }
+}


Thread (1 message)

  • Shivam Mathur
« previous php.webmaster (#34063) next »