[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); + } + } +}

« previous php.webmaster (#34063) next »