From 72d7410b65f501a756a4642150ef33b93d268e92 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 5 Oct 2026 22:53:45 +0400 Subject: [PATCH 01/14] refactor(downloader): select assets through a ranking pipeline Rules either remove an asset or rank it, and ranks compare in pipeline order, so a decision that depends on the other assets never removes anything. The gradual search becomes the same pipeline with OS and architecture ranked instead of required, which keeps its old order and tries each asset once. The pipeline is the interceptor chain from Testo without its ordering and conflict options: the rules run in the order the selector lists them, which is the priority of their ranks. Assisted-By: Claude Opus 5.5 --- src/Module/Common/Pipeline/Interceptor.php | 17 ++ src/Module/Common/Pipeline/Pipeline.php | 111 +++++++++ src/Module/Downloader/Downloader.php | 219 +++--------------- .../Internal/AssetSelection/AssetRule.php | 26 +++ .../Internal/AssetSelection/AssetSelector.php | 66 ++++++ .../Internal/AssetSelection/Candidate.php | 46 ++++ .../AssetSelection/Rule/ArchitectureRule.php | 35 +++ .../AssetSelection/Rule/ArchiveRule.php | 30 +++ .../AssetSelection/Rule/FormatRule.php | 39 ++++ .../AssetSelection/Rule/NamePatternRule.php | 31 +++ .../Rule/OperatingSystemRule.php | 35 +++ .../Internal/AssetSelection/Selection.php | 126 ++++++++++ .../Module/Common/Pipeline/PipelineTest.php | 71 ++++++ .../Common/Pipeline/Stub/HaltInterceptor.php | 26 +++ .../Common/Pipeline/Stub/TraceInterceptor.php | 33 +++ .../Module/Downloader/AssetSelectionTest.php | 2 + .../Unit/Module/Downloader/DownloaderTest.php | 3 + .../AssetSelection/AssetSelectorTest.php | 113 +++++++++ .../Internal/AssetSelection/SelectionTest.php | 85 +++++++ .../AssetSelection/Stub/NamedAssets.php | 39 ++++ 20 files changed, 969 insertions(+), 184 deletions(-) create mode 100644 src/Module/Common/Pipeline/Interceptor.php create mode 100644 src/Module/Common/Pipeline/Pipeline.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/AssetRule.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/AssetSelector.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Candidate.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/FormatRule.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Selection.php create mode 100644 tests/Unit/Module/Common/Pipeline/PipelineTest.php create mode 100644 tests/Unit/Module/Common/Pipeline/Stub/HaltInterceptor.php create mode 100644 tests/Unit/Module/Common/Pipeline/Stub/TraceInterceptor.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/NamedAssets.php diff --git a/src/Module/Common/Pipeline/Interceptor.php b/src/Module/Common/Pipeline/Interceptor.php new file mode 100644 index 00000000..da5789a7 --- /dev/null +++ b/src/Module/Common/Pipeline/Interceptor.php @@ -0,0 +1,17 @@ +with( + * static fn(Input $input): Output => new Output($input), + * 'process', + * ); + * $output = $pipeline($input); + * ``` + * + * @template-covariant TClass of TInterceptor + * @template TInput + * @template-covariant TOutput of mixed + * + * @psalm-immutable + * @internal + * + * @psalm-suppress PropertyNotSetInConstructor $method and $last are set later via {@see self::with()}. + */ +final class Pipeline +{ + /** @var non-empty-string */ + private string $method; + + /** @var callable(TInput): TOutput */ + private mixed $last; + + /** @var int<0, max> Current interceptor key */ + private int $current = 0; + + /** + * @param list $interceptors + */ + private function __construct( + private readonly array $interceptors, + ) {} + + /** + * Create a pipeline with given interceptors. + * + * @template-covariant TInt of TInterceptor + * @template TIn + * @template-covariant TOut + * @param TInterceptor ...$interceptors Instantiated interceptors, in the order they run. + * @return self + * + * @note Make sure that interceptors implement the same interface. + * @psalm-suppress InvalidTemplateParam, UndefinedDocblockClass, InvalidReturnType, InvalidReturnStatement + */ + public static function prepare(TInterceptor ...$interceptors): self + { + return new self(\array_values($interceptors)); + } + + /** + * @param non-empty-string $method Method name of the all interceptors. + * + * @return callable(object): TOutput + * @psalm-suppress InvalidReturnType, InvalidReturnStatement, MixedPropertyTypeCoercion + */ + public function with(callable $last, string $method): callable + { + $new = clone $this; + + $new->last = $last; + $new->method = $method; + + return $new; + } + + /** + * Must be used after {@see self::with()} method. + * + * @param TInput $input Input value for the first interceptor. + * + * @return TOutput + * @psalm-suppress ImpureFunctionCall, MixedReturnStatement + */ + public function __invoke(object $input): mixed + { + $interceptor = $this->interceptors[$this->current] ?? null; + + if ($interceptor === null) { + return ($this->last)($input); + } + + $next = $this->next(); + + return $interceptor->{$this->method}($input, $next); + } + + private function next(): self + { + $new = clone $this; + ++$new->current; + + return $new; + } +} diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index 2e712641..d1c25032 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -18,13 +18,13 @@ use Internal\DLoad\Module\Downloader\Exception\NotFound; use Internal\DLoad\Module\Downloader\Exception\ReleaseGone; use Internal\DLoad\Module\Downloader\Internal\Diagnostics\DownloadDiagnostics; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; use Internal\DLoad\Module\Downloader\Internal\DownloadContext; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; use Internal\DLoad\Module\Registry\RepositoryId; use Internal\DLoad\Module\Registry\VersionRegistry; use Internal\DLoad\Module\Repository\AssetInterface; -use Internal\DLoad\Module\Repository\Collection\AssetsCollection; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; use Internal\DLoad\Module\Repository\Exception\AssetNotFoundException; use Internal\DLoad\Module\Repository\Exception\RateLimitException; @@ -71,6 +71,7 @@ public function __construct( private readonly Stability $stability, private readonly ArchiveFactory $archiveService, private readonly VersionRegistry $registry, + private readonly AssetSelector $assetSelector, ) {} /** @@ -269,8 +270,8 @@ private function retryRepository(DownloadContext $context): ReleaseInterface /** * Processes a release to find suitable assets. * - * If software has binary configuration, filters assets using all criteria at once. - * If no binary configuration exists, applies filters gradually to find the best matching asset. + * If software has binary configuration, only assets for the host OS and architecture are tried. + * Otherwise assets for another platform are tried after them. * * @param DownloadContext $context Download context information * @return \Closure(): AssetInterface Closure that returns the selected asset @@ -286,147 +287,43 @@ private function processRelease(DownloadContext $context): \Closure $context->releaseAttempt->registerAssets($names); - return match (true) { - // Phar assets usually don't depend on OS or architecture, so we can use gradual filtering - $context->actionConfig->type === Type::Phar => $this->findAssetWithGradualFiltering($context), - // Use strict filtering when binary configuration exists - $context->software->binary !== null => $this->findAssetWithStrictFiltering($context), - // Use gradual filtering when no binary configuration exists - default => $this->findAssetWithGradualFiltering($context), - }; - }; - } - - /** - * Finds an asset using strict filtering with all criteria applied at once. - * - * @param DownloadContext $context Download context information - * @return AssetInterface Selected asset - * @throws NotFound If no suitable asset is found - */ - private function findAssetWithStrictFiltering(DownloadContext $context): AssetInterface - { - // Apply all filters at once: OS, architecture, and name pattern - $assetsCollection = $context->release->getAssets() - ->whereOperatingSystem($this->operatingSystem) - ->whereArchitecture($this->architecture) - ->whereNameMatches($context->repoConfig->assetPattern); - - /** @var AssetInterface[] $allAssets */ - $allAssets = $this->addFormatFilter($assetsCollection, $context->actionConfig)->toArray(); - $this->logger->debug('%d matching assets found.', \count($allAssets)); - - $allAssets === [] and throw new NotFound( - \sprintf( - 'no asset matches OS `%s`, architecture `%s`, name pattern `%s`%s', - $this->operatingSystem->value, - $this->architecture->value, - $context->repoConfig->assetPattern, - $this->describeFormatFilter($context->actionConfig), - ), - ); - - // Sort assets by priority and try to process them - $sortedAssets = $this->sortAssetsByPriority($allAssets, $this->archiveService->getSupportedExtensions()); - - return $this->tryProcessAssets($sortedAssets, $context); - } - - /** - * Finds an asset using gradual filtering, trying different combinations of criteria. - * - * @param DownloadContext $context Download context information - * @return AssetInterface Selected asset - * @throws NotFound If no suitable asset is found - */ - private function findAssetWithGradualFiltering(DownloadContext $context): AssetInterface - { - $assetsCollection = $context->release->getAssets() - ->whereNameMatches($context->repoConfig->assetPattern); - - $assetsCollection = $this->addFormatFilter($assetsCollection, $context->actionConfig); - $supportedExtensions = $this->archiveService->getSupportedExtensions(); - - // If we got here, no assets were found with any filter combination - \count($assetsCollection) === 0 and throw new NotFound( - \sprintf( - 'no asset matches name pattern `%s`%s', + // Phar assets usually don't depend on OS or architecture; without a binary configuration + // there is nothing to verify the choice, so assets for another platform stay as a fallback. + $strict = $context->actionConfig->type !== Type::Phar && $context->software->binary !== null; + $selection = $this->assetSelector->select( + $context->release->getAssets(), $context->repoConfig->assetPattern, - $this->describeFormatFilter($context->actionConfig), - ), - ); - - // Try #1: Filter by both OS and architecture (most specific) - $filteredAssets = $assetsCollection - ->whereOperatingSystem($this->operatingSystem) - ->whereArchitecture($this->architecture) - ->toArray(); - - if ($filteredAssets !== []) { - $this->logger->debug( - 'Found %d assets matching OS %s and architecture %s.', - \count($filteredAssets), - $this->operatingSystem->value, - $this->architecture->value, + $context->actionConfig->type, + $strict, ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - try { - return $this->tryProcessAssets($sortedAssets, $context); - } catch (NotFound $e) { - $this->logger->debug('Failed to process assets with OS and architecture filtering: %s', $e->getMessage()); - // Continue to next filter strategy - } - } - - // Try #2: Filter by OS only - $filteredAssets = $assetsCollection - ->whereOperatingSystem($this->operatingSystem) - ->toArray(); - - if ($filteredAssets !== []) { - $this->logger->debug( - 'Found %d assets matching OS %s (any architecture).', - \count($filteredAssets), - $this->operatingSystem->value, + $this->logger->debug('%d matching assets found.', \count($selection->candidates)); + + $selection->isEmpty() and throw new NotFound( + $strict + ? \sprintf( + 'no asset matches OS `%s`, architecture `%s`, name pattern `%s`%s', + $this->operatingSystem->value, + $this->architecture->value, + $context->repoConfig->assetPattern, + $this->describeFormatFilter($context->actionConfig), + ) + : \sprintf( + 'no asset matches name pattern `%s`%s', + $context->repoConfig->assetPattern, + $this->describeFormatFilter($context->actionConfig), + ), ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - try { - return $this->tryProcessAssets($sortedAssets, $context); - } catch (NotFound $e) { - $this->logger->debug('Failed to process assets with OS-only filtering: %s', $e->getMessage()); - // Continue to next filter strategy - } - } - - // Try #3: Filter by architecture only - $filteredAssets = $assetsCollection - ->whereArchitecture($this->architecture) - ->toArray(); - if ($filteredAssets !== []) { - $this->logger->debug( - 'Found %d assets matching architecture %s (any OS).', - \count($filteredAssets), - $this->architecture->value, - ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - try { - return $this->tryProcessAssets($sortedAssets, $context); - } catch (NotFound $e) { - $this->logger->debug('Failed to process assets with architecture-only filtering: %s', $e->getMessage()); - // Continue to next filter strategy + foreach ($selection->sorted() as $candidate) { + $this->logger->debug( + 'Asset `%s` ranked [%s].', + $candidate->asset->getName(), + \implode(', ', $candidate->ranks), + ); } - } - - // Try #4: Use name pattern only (least specific) - $filteredAssets = $assetsCollection->toArray(); - $this->logger->debug( - 'Found %d assets matching name pattern (any OS, any architecture).', - \count($filteredAssets), - ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - return $this->tryProcessAssets($sortedAssets, $context); + return $this->tryProcessAssets($selection->assets(), $context); + }; } /** @@ -500,36 +397,6 @@ private function describeFormatFilter(DownloadConfig $actionOptions): string }; } - /** - * Sorts assets by priority with supported archives first, then other files. - * - * @param AssetInterface[] $assets List of assets to sort - * @param list $supportedExtensions List of supported archive extensions - * @return AssetInterface[] Sorted list of assets - */ - private function sortAssetsByPriority(array $assets, array $supportedExtensions): array - { - $archiveAssets = []; - $otherAssets = []; - - foreach ($assets as $asset) { - $assetName = \strtolower($asset->getName()); - $isArchive = false; - - foreach ($supportedExtensions as $extension) { - if (\str_ends_with($assetName, '.' . $extension)) { - $archiveAssets[] = $asset; - $isArchive = true; - break; - } - } - - $isArchive or $otherAssets[] = $asset; - } - - return [...$archiveAssets, ...$otherAssets]; - } - /** * Downloads the selected asset to a temporary file. * @@ -590,20 +457,4 @@ private function getTempDirectory(): Path return $temp; } - - /** - * Adds format filter to the assets collection if specified in action options. - * - * @param AssetsCollection $collection Collection of assets to filter - * @param DownloadConfig $actionOptions Download action options - * @return AssetsCollection Filtered collection - */ - private function addFormatFilter(AssetsCollection $collection, DownloadConfig $actionOptions): AssetsCollection - { - return match ($actionOptions->type) { - Type::Phar => $collection->whereFileExtensions(['phar']), - Type::Archive => $collection->whereFileExtensions($this->archiveService->getSupportedExtensions()), - default => $collection, - }; - } } diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetRule.php b/src/Module/Downloader/Internal/AssetSelection/AssetRule.php new file mode 100644 index 00000000..58eeadcd --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/AssetRule.php @@ -0,0 +1,26 @@ + + * + * @internal + * @psalm-internal Internal\DLoad\Module\Downloader + */ +interface AssetRule extends Interceptor +{ + /** + * @param callable(Selection): Selection $next + */ + public function select(Selection $selection, callable $next): Selection; +} diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php new file mode 100644 index 00000000..500751bd --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -0,0 +1,66 @@ +select($release->getAssets(), '/^tool-.*$/', Type::Archive, strict: true); + * $selection->isEmpty() or $best = $selection->assets()[0]; + * ``` + * + * @internal + * @psalm-internal Internal\DLoad\Module\Downloader + */ +final class AssetSelector +{ + /** @var callable(Selection): Selection */ + private $pipeline; + + public function __construct( + OperatingSystem $operatingSystem, + Architecture $architecture, + ArchiveFactory $archiveFactory, + ) { + /** + * Rules run in this order, which is also the priority of their ranks. + * + * @see AssetRule::select() + * @var callable(Selection): Selection $pipeline + */ + $pipeline = Pipeline::prepare( + new NamePatternRule(), + new FormatRule($archiveFactory), + new OperatingSystemRule($operatingSystem), + new ArchitectureRule($architecture), + new ArchiveRule($archiveFactory), + )->with(static fn(Selection $selection): Selection => $selection, 'select'); + $this->pipeline = $pipeline; + } + + /** + * @param iterable $assets + * @param non-empty-string $assetPattern Pattern the asset names must match. + * @param Type|null $type Download action type restricting the asset format. + * @param bool $strict Whether assets for another OS or architecture are removed rather than ranked lower. + */ + public function select(iterable $assets, string $assetPattern, ?Type $type, bool $strict): Selection + { + return ($this->pipeline)(Selection::create($assets, $assetPattern, $type, $strict)); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Candidate.php b/src/Module/Downloader/Internal/AssetSelection/Candidate.php new file mode 100644 index 00000000..831065cb --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Candidate.php @@ -0,0 +1,46 @@ + $position Position of the asset in the release, the last tie-breaker. + * @param list $ranks One key per ranking rule in the pipeline order; lower is better. + */ + public function __construct( + public readonly AssetInterface $asset, + public readonly int $position, + public readonly array $ranks = [], + ) {} + + /** + * @param list $extensions File extensions without the leading dot. + */ + public function hasExtension(array $extensions): bool + { + $name = \strtolower($this->asset->getName()); + foreach ($extensions as $extension) { + if (\str_ends_with($name, '.' . $extension)) { + return true; + } + } + + return false; + } + + public function withRank(int $rank): self + { + return new self($this->asset, $this->position, [...$this->ranks, $rank]); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php new file mode 100644 index 00000000..518dff61 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php @@ -0,0 +1,35 @@ + $candidate->asset->getArchitecture() === $this->architecture; + + return $next( + $selection->strict + ? $selection->remove(static fn(Candidate $candidate): bool => !$fits($candidate)) + : $selection->prefer($fits), + ); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php new file mode 100644 index 00000000..78a7b065 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php @@ -0,0 +1,30 @@ +archiveFactory->getSupportedExtensions(); + + return $next($selection->prefer(static fn(Candidate $candidate): bool => $candidate->hasExtension($extensions))); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/FormatRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/FormatRule.php new file mode 100644 index 00000000..ef9a5878 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/FormatRule.php @@ -0,0 +1,39 @@ +type) { + Type::Phar => ['phar'], + Type::Archive => $this->archiveFactory->getSupportedExtensions(), + default => null, + }; + + $extensions === null or $selection = $selection->remove( + static fn(Candidate $candidate): bool => !$candidate->hasExtension($extensions), + ); + + return $next($selection); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php new file mode 100644 index 00000000..b75e228b --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php @@ -0,0 +1,31 @@ +assetPattern; + + return $next($selection->remove( + static fn(Candidate $candidate): bool => @\preg_match( + $pattern, + $candidate->asset->getName(), + flags: \PREG_NO_ERROR, + ) !== 1, + )); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php new file mode 100644 index 00000000..55e60ed6 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php @@ -0,0 +1,35 @@ + $candidate->asset->getOperatingSystem() === $this->operatingSystem; + + return $next( + $selection->strict + ? $selection->remove(static fn(Candidate $candidate): bool => !$fits($candidate)) + : $selection->prefer($fits), + ); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Selection.php b/src/Module/Downloader/Internal/AssetSelection/Selection.php new file mode 100644 index 00000000..f06f63be --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Selection.php @@ -0,0 +1,126 @@ + $candidates + * @param non-empty-string $assetPattern Pattern the asset names must match. + * @param Type|null $type Download action type restricting the asset format. + * @param bool $strict Whether assets for another OS or architecture are removed rather than ranked lower. + */ + private function __construct( + public readonly array $candidates, + public readonly string $assetPattern, + public readonly ?Type $type, + public readonly bool $strict, + ) {} + + /** + * @param iterable $assets + * @param non-empty-string $assetPattern + */ + public static function create(iterable $assets, string $assetPattern, ?Type $type, bool $strict): self + { + $candidates = []; + foreach ($assets as $asset) { + $candidates[] = new Candidate($asset, \count($candidates)); + } + + return new self($candidates, $assetPattern, $type, $strict); + } + + /** + * Removes the candidates matching the predicate. + * + * The predicate looks at one candidate only: a decision that depends on the other candidates + * is a rank, see {@see self::rank()}. + * + * @param \Closure(Candidate): bool $predicate + */ + public function remove(\Closure $predicate): self + { + return $this->withCandidates(\array_values(\array_filter( + $this->candidates, + static fn(Candidate $candidate): bool => !$predicate($candidate), + ))); + } + + /** + * Gives every candidate a rank; lower is better. + * + * @param \Closure(Candidate): int $rank + */ + public function rank(\Closure $rank): self + { + return $this->withCandidates(\array_map( + static fn(Candidate $candidate): Candidate => $candidate->withRank($rank($candidate)), + $this->candidates, + )); + } + + /** + * Moves the candidates matching the predicate ahead of the others. + * + * @param \Closure(Candidate): bool $predicate + */ + public function prefer(\Closure $predicate): self + { + return $this->rank(static fn(Candidate $candidate): int => $predicate($candidate) ? 0 : 1); + } + + public function isEmpty(): bool + { + return $this->candidates === []; + } + + /** + * Candidates from the best to the worst. + * + * @return list + */ + public function sorted(): array + { + $candidates = $this->candidates; + \usort( + $candidates, + static fn(Candidate $a, Candidate $b): int => [...$a->ranks, $a->position] <=> [...$b->ranks, $b->position], + ); + + return $candidates; + } + + /** + * Assets from the best to the worst. + * + * @return list + */ + public function assets(): array + { + return \array_map(static fn(Candidate $candidate): AssetInterface => $candidate->asset, $this->sorted()); + } + + /** + * @param list $candidates + */ + private function withCandidates(array $candidates): self + { + return new self($candidates, $this->assetPattern, $this->type, $this->strict); + } +} diff --git a/tests/Unit/Module/Common/Pipeline/PipelineTest.php b/tests/Unit/Module/Common/Pipeline/PipelineTest.php new file mode 100644 index 00000000..3e079d4d --- /dev/null +++ b/tests/Unit/Module/Common/Pipeline/PipelineTest.php @@ -0,0 +1,71 @@ +with(self::last(...), 'handle'); + + $first = $pipeline(new \ArrayObject()); + $second = $pipeline(new \ArrayObject()); + + Assert::same($first->getArrayCopy(), $second->getArrayCopy()); + } + + /** + * @return list + */ + private static function run(TraceInterceptor|HaltInterceptor ...$interceptors): array + { + $trace = new \ArrayObject(); + Pipeline::prepare(...$interceptors)->with(self::last(...), 'handle')($trace); + + return $trace->getArrayCopy(); + } + + /** + * @param \ArrayObject $trace + * @return \ArrayObject + */ + private static function last(\ArrayObject $trace): \ArrayObject + { + $trace[] = 'last'; + + return $trace; + } +} diff --git a/tests/Unit/Module/Common/Pipeline/Stub/HaltInterceptor.php b/tests/Unit/Module/Common/Pipeline/Stub/HaltInterceptor.php new file mode 100644 index 00000000..2a55d359 --- /dev/null +++ b/tests/Unit/Module/Common/Pipeline/Stub/HaltInterceptor.php @@ -0,0 +1,26 @@ +, \ArrayObject> + */ +final class HaltInterceptor implements Interceptor +{ + /** + * @param \ArrayObject $trace + * @return \ArrayObject + */ + public function handle(\ArrayObject $trace, callable $next): \ArrayObject + { + $trace[] = 'halt'; + + return $trace; + } +} diff --git a/tests/Unit/Module/Common/Pipeline/Stub/TraceInterceptor.php b/tests/Unit/Module/Common/Pipeline/Stub/TraceInterceptor.php new file mode 100644 index 00000000..d681465f --- /dev/null +++ b/tests/Unit/Module/Common/Pipeline/Stub/TraceInterceptor.php @@ -0,0 +1,33 @@ +, \ArrayObject> + */ +final class TraceInterceptor implements Interceptor +{ + public function __construct( + public readonly string $label, + ) {} + + /** + * @param \ArrayObject $trace + * @param callable(\ArrayObject): \ArrayObject $next + * @return \ArrayObject + */ + public function handle(\ArrayObject $trace, callable $next): \ArrayObject + { + $trace[] = "{$this->label}:in"; + $result = $next($trace); + $trace[] = "{$this->label}:out"; + + return $result; + } +} diff --git a/tests/Unit/Module/Downloader/AssetSelectionTest.php b/tests/Unit/Module/Downloader/AssetSelectionTest.php index c6a62721..f3d62b50 100644 --- a/tests/Unit/Module/Downloader/AssetSelectionTest.php +++ b/tests/Unit/Module/Downloader/AssetSelectionTest.php @@ -13,6 +13,7 @@ use Internal\DLoad\Module\Config\Schema\Downloader as DownloaderConfig; use Internal\DLoad\Module\Config\Schema\Embed\Software; use Internal\DLoad\Module\Downloader\Downloader; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; use Internal\DLoad\Module\Repository\RepositoryProvider; @@ -201,6 +202,7 @@ private function downloadBun(Software $software, OperatingSystem $os, Architectu stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: new RecordingRegistry(), + assetSelector: new AssetSelector($os, $arch, new ArchiveFactory()), ); $task = $downloader->download($software, DownloadConfig::fromSoftwareId('bun'), static fn(): null => null); diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 8c6fa654..49d4c273 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -14,6 +14,7 @@ use Internal\DLoad\Module\Config\Schema\Downloader as DownloaderConfig; use Internal\DLoad\Module\Config\Schema\Embed\Software; use Internal\DLoad\Module\Downloader\Downloader; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; use Internal\DLoad\Module\Downloader\Exception\DownloadFailed; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; @@ -426,6 +427,7 @@ private function download(array|SequenceRepositoryFactoryStub $repositories, str stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, + assetSelector: new AssetSelector(OperatingSystem::tryFromString('linux') ?? throw new \LogicException(), Architecture::tryFromString('amd64') ?? throw new \LogicException(), new ArchiveFactory()), ); $software = Software::fromArray([ @@ -474,6 +476,7 @@ private function makeDownloader(SequenceRepositoryFactoryStub $factory, Download stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, + assetSelector: new AssetSelector(OperatingSystem::tryFromString('linux') ?? throw new \LogicException(), Architecture::tryFromString('amd64') ?? throw new \LogicException(), new ArchiveFactory()), ); } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php new file mode 100644 index 00000000..2cd03408 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -0,0 +1,113 @@ + $assets + * @param non-empty-string $pattern + * @return list + */ + private static function select( + array $assets, + string $pattern = '/.*/', + ?Type $type = null, + bool $strict = true, + OperatingSystem $os = OperatingSystem::Linux, + Architecture $arch = Architecture::X86_64, + ): array { + $selection = (new AssetSelector($os, $arch, new ArchiveFactory())) + ->select(NamedAssets::create(...$assets), $pattern, $type, $strict); + + return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $selection->assets()); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php new file mode 100644 index 00000000..3a7dd27c --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php @@ -0,0 +1,85 @@ +remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'b'); + + Assert::same(self::names($selection), ['a', 'c']); + } + + #[Test] + public function anEarlierRankOutweighsALaterOne(): void + { + $selection = self::selection('a-slow', 'b-fast', 'c-fast') + ->prefer(static fn(Candidate $candidate): bool => $candidate->asset->getName() !== 'b-fast') + ->prefer(static fn(Candidate $candidate): bool => \str_ends_with($candidate->asset->getName(), 'fast')); + + Assert::same(self::names($selection), ['c-fast', 'a-slow', 'b-fast']); + } + + #[Test] + public function aLaterRankOnlyOrdersCandidatesEqualByTheEarlierOnes(): void + { + $selection = self::selection('aaa', 'b', 'cc') + ->rank(static fn(Candidate $candidate): int => \strlen($candidate->asset->getName())); + + Assert::same(self::names($selection), ['b', 'cc', 'aaa']); + } + + #[Test] + public function equallyRankedCandidatesKeepTheReleaseOrder(): void + { + $selection = self::selection('b', 'a', 'c')->rank(static fn(): int => 1); + + Assert::same(self::names($selection), ['b', 'a', 'c']); + } + + #[Test] + public function aSelectionWithoutCandidatesIsEmpty(): void + { + $selection = self::selection('a')->remove(static fn(): bool => true); + + Assert::true($selection->isEmpty()); + Assert::same($selection->assets(), []); + } + + /** + * @param non-empty-string ...$names + */ + private static function selection(string ...$names): Selection + { + return Selection::create(NamedAssets::create(...$names), '/.*/', null, strict: true); + } + + /** + * @return list + */ + private static function names(Selection $selection): array + { + return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $selection->assets()); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/NamedAssets.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/NamedAssets.php new file mode 100644 index 00000000..afad5895 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/NamedAssets.php @@ -0,0 +1,39 @@ + + */ + public static function create(string ...$names): array + { + $release = new ReleaseStub(new RepositoryStub('owner/repo'), 'v1.0.0', Version::fromVersionString('v1.0.0')); + + return \array_values(\array_map( + static fn(string $name): AssetInterface => new AssetStub( + $release, + $name, + 'https://example.com/' . $name, + OperatingSystem::tryFromBuildName($name), + Architecture::tryFromBuildName($name), + ), + $names, + )); + } +} From d01271c440912ecc94ff6e6d29edbc95c55761f0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 5 Oct 2026 22:53:58 +0400 Subject: [PATCH 02/14] feat(downloader): recognize Android builds An `android` token in an asset name now wins over `linux`, so Android builds are no longer picked on a Linux host. On an Android host (detected by `ANDROID_ROOT`) Linux builds stay a fallback, since static Linux binaries run there. Assisted-By: Claude Opus 5.5 --- src/Module/Common/OperatingSystem.php | 12 +++++++- .../Rule/OperatingSystemRule.php | 30 ++++++++++++++----- .../Module/Common/OperatingSystemTest.php | 2 ++ .../AssetSelection/AssetSelectorTest.php | 19 ++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/Module/Common/OperatingSystem.php b/src/Module/Common/OperatingSystem.php index 22f4b4b4..ca189b56 100644 --- a/src/Module/Common/OperatingSystem.php +++ b/src/Module/Common/OperatingSystem.php @@ -28,6 +28,7 @@ enum OperatingSystem: string implements Factoriable case BSD = 'freebsd'; case Linux = 'linux'; case Windows = 'windows'; + case Android = 'android'; case Alpine = 'unknown-musl'; private const ERROR_UNKNOWN_OS = 'Current OS `%s` may not be supported'; @@ -39,9 +40,12 @@ public static function create(Build $config): static public static function fromGlobals(): self { - return self::tryFromString(\PHP_OS_FAMILY) ?? throw new \OutOfRangeException( + $os = self::tryFromString(\PHP_OS_FAMILY) ?? throw new \OutOfRangeException( \sprintf(self::ERROR_UNKNOWN_OS, \PHP_OS_FAMILY), ); + + // PHP reports Android as Linux; the variable is set by the Android runtime and Termux + return $os === self::Linux && \getenv('ANDROID_ROOT') !== false ? self::Android : $os; } public static function tryFromString(string $name): ?self @@ -50,6 +54,7 @@ public static function tryFromString(string $name): ?self 'windows', 'win32', 'win64' => self::Windows, 'bsd', 'freebsd' => self::BSD, 'darwin', 'macos' => self::Darwin, + 'android' => self::Android, 'linux' => \str_contains(\PHP_OS, 'alpine') ? self::Alpine : self::Linux, @@ -59,6 +64,11 @@ public static function tryFromString(string $name): ?self public static function tryFromBuildName(string $name): ?self { + // Android builds are also named after Linux, like `aarch64-linux-android` + if (\preg_match('/(?:\b|_)android(?:\b|_)/i', $name) === 1) { + return self::Android; + } + return \preg_match( '/(?:\b|_)(windows|linux|darwin|macos|alpine|bsd|freebsd|win32|win64)(?:\b|_)/i', $name, diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php index 55e60ed6..be2a11e1 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php @@ -10,8 +10,9 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Selection; /** - * Keeps assets built for the host operating system: removes the others in a strict selection, - * ranks them lower otherwise. + * Prefers assets built for the host operating system, then the ones it can run. + * + * A strict selection removes the assets the host cannot run; otherwise they are ranked last. * * @internal * @psalm-internal Internal\DLoad\Module\Downloader @@ -24,12 +25,25 @@ public function __construct( public function select(Selection $selection, callable $next): Selection { - $fits = fn(Candidate $candidate): bool => $candidate->asset->getOperatingSystem() === $this->operatingSystem; - - return $next( - $selection->strict - ? $selection->remove(static fn(Candidate $candidate): bool => !$fits($candidate)) - : $selection->prefer($fits), + $selection->strict and $selection = $selection->remove( + fn(Candidate $candidate): bool => $this->rank($candidate->asset->getOperatingSystem()) === null, ); + + return $next($selection->rank( + fn(Candidate $candidate): int => $this->rank($candidate->asset->getOperatingSystem()) ?? 2, + )); + } + + /** + * @return int<0, 1>|null Null when the host cannot run the asset. + */ + private function rank(?OperatingSystem $os): ?int + { + return match (true) { + $os === $this->operatingSystem => 0, + // Static Linux binaries run on Android, while Android builds need its runtime + $os === OperatingSystem::Linux && $this->operatingSystem === OperatingSystem::Android => 1, + default => null, + }; } } diff --git a/tests/Unit/Module/Common/OperatingSystemTest.php b/tests/Unit/Module/Common/OperatingSystemTest.php index 40b44368..a0eca35c 100644 --- a/tests/Unit/Module/Common/OperatingSystemTest.php +++ b/tests/Unit/Module/Common/OperatingSystemTest.php @@ -21,6 +21,8 @@ public static function provideBuildNames(): iterable yield ['protoc-27.3-win64.zip', OperatingSystem::Windows]; yield ['protoc-27.3-win32.zip', OperatingSystem::Windows]; yield ['temporal-test-server_1.33.0_macOS_arm64.tar.gz', OperatingSystem::Darwin]; + yield ['bun-linux-x64-android-baseline.zip', OperatingSystem::Android]; + yield ['tool-aarch64-linux-android.tar.gz', OperatingSystem::Android]; } #[DataProvider('provideBuildNames')] diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 2cd03408..e9e3a614 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -65,6 +65,25 @@ public function aStrictSelectionRemovesOtherPlatforms(): void Assert::same($names, ['tool-linux-amd64.zip']); } + #[Test] + public function androidBuildsAreNotSelectedOnLinux(): void + { + $names = self::select(['tool-linux-amd64-android.zip', 'tool-linux-amd64.zip']); + + Assert::same($names, ['tool-linux-amd64.zip']); + } + + #[Test] + public function androidPrefersAndroidBuildsAndFallsBackToLinuxOnes(): void + { + $names = self::select( + ['tool-darwin-amd64.zip', 'tool-linux-amd64.zip', 'tool-linux-amd64-android.zip'], + os: OperatingSystem::Android, + ); + + Assert::same($names, ['tool-linux-amd64-android.zip', 'tool-linux-amd64.zip']); + } + #[Test] public function aGradualSelectionPutsOtherPlatformsAfterTheHostOne(): void { From 625d40f4f3635227a97ba6c62b4f91dfeb921d94 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 5 Oct 2026 22:53:59 +0400 Subject: [PATCH 03/14] feat(downloader): prefer the build for the host libc A musl host is detected by the presence of the musl dynamic loader, or set with `--os alpine`. musl builds rank first there and last elsewhere, but are never removed: some tools publish only a static musl build for Linux. Alpine is no longer a separate OS: it was never detected, and treating it as one would have excluded every plain Linux build. Assisted-By: Claude Opus 5.5 --- README-es.md | 2 +- README-ru.md | 2 +- README-zh.md | 2 +- README.md | 2 +- src/Bootstrap.php | 2 + src/Command/Get.php | 6 +- src/Module/Common/Libc.php | 56 +++++++++++++ src/Module/Common/OperatingSystem.php | 21 +++-- .../Internal/AssetSelection/AssetName.php | 27 ++++++ .../Internal/AssetSelection/AssetSelector.php | 4 + .../Internal/AssetSelection/Candidate.php | 3 +- .../Internal/AssetSelection/Rule/LibcRule.php | 33 ++++++++ .../Internal/AssetSelection/Selection.php | 2 +- tests/Unit/Module/Common/LibcTest.php | 46 ++++++++++ .../Module/Common/OperatingSystemTest.php | 3 +- .../Module/Downloader/AssetSelectionTest.php | 83 +++++++++++++++++-- .../Unit/Module/Downloader/DownloaderTest.php | 5 +- .../AssetSelection/AssetSelectorTest.php | 37 ++++++++- 18 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 src/Module/Common/Libc.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/AssetName.php create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php create mode 100644 tests/Unit/Module/Common/LibcTest.php diff --git a/README-es.md b/README-es.md index f9963562..1c0396df 100644 --- a/README-es.md +++ b/README-es.md @@ -169,7 +169,7 @@ También puedes descargar la versión más reciente desde [GitHub releases](http |--------|-------------|-------------------| | `--path` | Directorio donde guardar los binarios | Directorio actual | | `--arch` | Arquitectura de destino (amd64, arm64) | Arquitectura del sistema | -| `--os` | Sistema operativo de destino (linux, darwin, windows) | SO actual | +| `--os` | Sistema operativo de destino (linux, alpine, darwin, windows, android) | SO actual | | `--stability` | Estabilidad del release (stable, beta) | stable | | `--config` | Ruta al archivo de configuración | ./dload.xml | | `--force`, `-f` | Forzar descarga aunque el binario ya exista | false | diff --git a/README-ru.md b/README-ru.md index 38d78516..4d4db625 100644 --- a/README-ru.md +++ b/README-ru.md @@ -170,7 +170,7 @@ composer require internal/dload -W |-------|----------|--------------| | `--path` | Папка для хранения бинарников | Текущая папка | | `--arch` | Целевая архитектура (amd64, arm64) | Архитектура системы | -| `--os` | Целевая ОС (linux, darwin, windows) | Текущая ОС | +| `--os` | Целевая ОС (linux, alpine, darwin, windows, android) | Текущая ОС | | `--stability` | Стабильность релиза (stable, beta) | stable | | `--config` | Путь к конфигурационному файлу | ./dload.xml | | `--force`, `-f` | Принудительная загрузка даже если бинарник уже есть | false | diff --git a/README-zh.md b/README-zh.md index 2316910f..8d5db4b9 100644 --- a/README-zh.md +++ b/README-zh.md @@ -169,7 +169,7 @@ composer require internal/dload -W |--------|-------------|---------| | `--path` | 二进制文件存储目录 | 当前目录 | | `--arch` | 目标架构 (amd64, arm64) | 系统架构 | -| `--os` | 目标操作系统 (linux, darwin, windows) | 当前操作系统 | +| `--os` | 目标操作系统 (linux, alpine, darwin, windows, android) | 当前操作系统 | | `--stability` | 发布稳定性 (stable, beta) | stable | | `--config` | 配置文件路径 | ./dload.xml | | `--force`, `-f` | 即使二进制文件已存在也强制下载 | false | diff --git a/README.md b/README.md index cc98273c..5ac825bf 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ Alternatively, you can download the latest release from [GitHub releases](https: |--------|-------------|---------| | `--path` | Directory to store binaries | Current directory | | `--arch` | Target architecture (amd64, arm64) | System architecture | -| `--os` | Target OS (linux, darwin, windows) | Current OS | +| `--os` | Target OS (linux, alpine, darwin, windows, android) | Current OS | | `--stability` | Release stability (stable, beta) | stable | | `--config` | Path to configuration file | ./dload.xml | | `--force`, `-f` | Force download even if binary exists | false | diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 65ab415d..7b1852ff 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -10,6 +10,7 @@ use Internal\DLoad\Module\Binary\Internal\BinaryProviderImpl; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\Internal\Injection\ConfigInflector; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Cache as CacheConfig; @@ -115,6 +116,7 @@ public function withConfig( // Register bindings $this->container->bind(Architecture::class); $this->container->bind(OperatingSystem::class); + $this->container->bind(Libc::class); $this->container->bind(Stability::class); $this->container->bind( RepositoryProvider::class, diff --git a/src/Command/Get.php b/src/Command/Get.php index 150be58d..320bf1ae 100644 --- a/src/Command/Get.php +++ b/src/Command/Get.php @@ -7,6 +7,7 @@ use Internal\Container\Container; use Internal\DLoad\DLoad; use Internal\DLoad\Module\Common\Architecture; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; @@ -117,7 +118,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln('Architecture: ' . $container->get(Architecture::class)->name); - $output->writeln(' Op. system: ' . $container->get(OperatingSystem::class)->name); + $output->writeln( + ' Op. system: ' . $container->get(OperatingSystem::class)->name + . ($container->get(Libc::class) === Libc::Musl ? ' (musl)' : ''), + ); $output->writeln(' Stability: ' . $container->get(Stability::class)->name); $actions === [] and throw new \RuntimeException('No software to download.'); diff --git a/src/Module/Common/Libc.php b/src/Module/Common/Libc.php new file mode 100644 index 00000000..5bac92c6 --- /dev/null +++ b/src/Module/Common/Libc.php @@ -0,0 +1,56 @@ +get(Libc::class); + * + * // Or read it from a build name + * $libc = Libc::tryFromBuildName('tool-x86_64-unknown-linux-musl.tar.gz'); + * ``` + * + * @internal + */ +enum Libc: string implements Factoriable +{ + case Gnu = 'gnu'; + case Musl = 'musl'; + + public static function create(Build $config): self + { + return match (\strtolower((string) $config->os)) { + 'alpine', 'unknown-musl' => self::Musl, + default => self::fromGlobals(), + }; + } + + public static function fromGlobals(): self + { + // The musl dynamic loader exists on musl systems only, and checking it runs nothing + return \PHP_OS_FAMILY === 'Linux' && \glob('/lib/ld-musl-*.so.1') !== [] + ? self::Musl + : self::Gnu; + } + + public static function tryFromBuildName(string $name): ?self + { + if (\preg_match('/(?:\b|_)(musl(?:eabi(?:hf)?)?|alpine|gnu(?:eabi(?:hf)?)?|glibc)(?:\b|_)/i', $name, $matches) !== 1) { + return null; + } + + $token = \strtolower($matches[1]); + return \str_starts_with($token, 'musl') || $token === 'alpine' ? self::Musl : self::Gnu; + } +} diff --git a/src/Module/Common/OperatingSystem.php b/src/Module/Common/OperatingSystem.php index ca189b56..fc20f411 100644 --- a/src/Module/Common/OperatingSystem.php +++ b/src/Module/Common/OperatingSystem.php @@ -29,13 +29,14 @@ enum OperatingSystem: string implements Factoriable case Linux = 'linux'; case Windows = 'windows'; case Android = 'android'; - case Alpine = 'unknown-musl'; private const ERROR_UNKNOWN_OS = 'Current OS `%s` may not be supported'; public static function create(Build $config): static { - return self::tryFrom((string) $config->os) ?? self::fromGlobals(); + return self::tryFrom((string) $config->os) + ?? self::tryFromString((string) $config->os) + ?? self::fromGlobals(); } public static function fromGlobals(): self @@ -55,9 +56,8 @@ public static function tryFromString(string $name): ?self 'bsd', 'freebsd' => self::BSD, 'darwin', 'macos' => self::Darwin, 'android' => self::Android, - 'linux' => \str_contains(\PHP_OS, 'alpine') - ? self::Alpine - : self::Linux, + // The libc is a separate trait, see {@see Libc} + 'linux', 'alpine', 'unknown-musl' => self::Linux, default => null, }; } @@ -69,13 +69,16 @@ public static function tryFromBuildName(string $name): ?self return self::Android; } - return \preg_match( + if (\preg_match( '/(?:\b|_)(windows|linux|darwin|macos|alpine|bsd|freebsd|win32|win64)(?:\b|_)/i', $name, $matches, - ) === 1 - ? self::tryFromString(\strtolower($matches[1])) - : null; + ) === 1) { + return self::tryFromString(\strtolower($matches[1])); + } + + // Only Linux builds name the libc alone, like `unknown-musl` + return Libc::tryFromBuildName($name) === Libc::Musl ? self::Linux : null; } public function getBinaryExtension(): string diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetName.php b/src/Module/Downloader/Internal/AssetSelection/AssetName.php new file mode 100644 index 00000000..d970c750 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/AssetName.php @@ -0,0 +1,27 @@ +with(static fn(Selection $selection): Selection => $selection, 'select'); $this->pipeline = $pipeline; diff --git a/src/Module/Downloader/Internal/AssetSelection/Candidate.php b/src/Module/Downloader/Internal/AssetSelection/Candidate.php index 831065cb..767d7852 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Candidate.php +++ b/src/Module/Downloader/Internal/AssetSelection/Candidate.php @@ -20,6 +20,7 @@ final class Candidate */ public function __construct( public readonly AssetInterface $asset, + public readonly AssetName $name, public readonly int $position, public readonly array $ranks = [], ) {} @@ -41,6 +42,6 @@ public function hasExtension(array $extensions): bool public function withRank(int $rank): self { - return new self($this->asset, $this->position, [...$this->ranks, $rank]); + return new self($this->asset, $this->name, $this->position, [...$this->ranks, $rank]); } } diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php new file mode 100644 index 00000000..cff387d0 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php @@ -0,0 +1,33 @@ +prefer( + fn(Candidate $candidate): bool => ($candidate->name->libc ?? Libc::Gnu) === $this->libc, + )); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Selection.php b/src/Module/Downloader/Internal/AssetSelection/Selection.php index f06f63be..91a3b403 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Selection.php +++ b/src/Module/Downloader/Internal/AssetSelection/Selection.php @@ -40,7 +40,7 @@ public static function create(iterable $assets, string $assetPattern, ?Type $typ { $candidates = []; foreach ($assets as $asset) { - $candidates[] = new Candidate($asset, \count($candidates)); + $candidates[] = new Candidate($asset, AssetName::fromString($asset->getName()), \count($candidates)); } return new self($candidates, $assetPattern, $type, $strict); diff --git a/tests/Unit/Module/Common/LibcTest.php b/tests/Unit/Module/Common/LibcTest.php new file mode 100644 index 00000000..51285bc7 --- /dev/null +++ b/tests/Unit/Module/Common/LibcTest.php @@ -0,0 +1,46 @@ +os = 'alpine'; + + Assert::same(Libc::create($build), Libc::Musl); + } +} diff --git a/tests/Unit/Module/Common/OperatingSystemTest.php b/tests/Unit/Module/Common/OperatingSystemTest.php index a0eca35c..055537eb 100644 --- a/tests/Unit/Module/Common/OperatingSystemTest.php +++ b/tests/Unit/Module/Common/OperatingSystemTest.php @@ -17,7 +17,8 @@ public static function provideBuildNames(): iterable yield ['temporal_cli_0.13.2_windows_amd64.tar.gz', OperatingSystem::Windows]; yield ['roadrunner-2024.1.5-linux-amd64.deb', OperatingSystem::Linux]; yield ['roadrunner-2024.1.5-linux-amd64.tar.gz', OperatingSystem::Linux]; - yield ['roadrunner-2024.1.5-unknown-musl-amd64.tar.gz', null]; + yield ['roadrunner-2024.1.5-unknown-musl-amd64.tar.gz', OperatingSystem::Linux]; + yield ['tool-alpine-amd64.tar.gz', OperatingSystem::Linux]; yield ['protoc-27.3-win64.zip', OperatingSystem::Windows]; yield ['protoc-27.3-win32.zip', OperatingSystem::Windows]; yield ['temporal-test-server_1.33.0_macOS_arm64.tar.gz', OperatingSystem::Darwin]; diff --git a/tests/Unit/Module/Downloader/AssetSelectionTest.php b/tests/Unit/Module/Downloader/AssetSelectionTest.php index f3d62b50..7cd2c0d0 100644 --- a/tests/Unit/Module/Downloader/AssetSelectionTest.php +++ b/tests/Unit/Module/Downloader/AssetSelectionTest.php @@ -7,6 +7,7 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\FileSystem\FS; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; @@ -85,6 +86,30 @@ final class AssetSelectionTest 'SHASUMS256.txt.asc', ]; + /** + * Assets of the Mago 1.51.2 release: Rust target triples with glibc and musl builds. + */ + private const MAGO_ASSETS = [ + 'mago-1.51.2-aarch64-apple-darwin.tar.gz', + 'mago-1.51.2-aarch64-unknown-linux-gnu.tar.gz', + 'mago-1.51.2-aarch64-unknown-linux-musl.tar.gz', + 'mago-1.51.2-arm-unknown-linux-gnueabi.tar.gz', + 'mago-1.51.2-arm-unknown-linux-gnueabihf.tar.gz', + 'mago-1.51.2-arm-unknown-linux-musleabi.tar.gz', + 'mago-1.51.2-arm-unknown-linux-musleabihf.tar.gz', + 'mago-1.51.2-armv7-unknown-linux-gnueabihf.tar.gz', + 'mago-1.51.2-armv7-unknown-linux-musleabihf.tar.gz', + 'mago-1.51.2-wasm.tar.gz', + 'mago-1.51.2-x86_64-apple-darwin.tar.gz', + 'mago-1.51.2-x86_64-pc-windows-gnu.tar.gz', + 'mago-1.51.2-x86_64-pc-windows-msvc.zip', + 'mago-1.51.2-x86_64-unknown-freebsd.tar.gz', + 'mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz', + 'mago-1.51.2-x86_64-unknown-linux-musl.tar.gz', + 'source-code.tar.gz', + 'source-code.zip', + ]; + private string $tempDir; public static function provideBunHosts(): \Generator @@ -138,6 +163,31 @@ public function bunPlainBuildIsSelectedWithABroadAssetPattern( Assert::same($result->file->getFilename(), $expected); } + public static function provideMagoHosts(): \Generator + { + yield 'Linux x64 glibc' => [Architecture::X86_64, Libc::Gnu, 'mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz']; + yield 'Linux x64 musl' => [Architecture::X86_64, Libc::Musl, 'mago-1.51.2-x86_64-unknown-linux-musl.tar.gz']; + yield 'Linux arm64 glibc' => [Architecture::ARM_64, Libc::Gnu, 'mago-1.51.2-aarch64-unknown-linux-gnu.tar.gz']; + yield 'Linux arm64 musl' => [Architecture::ARM_64, Libc::Musl, 'mago-1.51.2-aarch64-unknown-linux-musl.tar.gz']; + } + + #[DataProvider('provideMagoHosts')] + #[Test] + public function magoBuildForTheHostLibcIsSelected(Architecture $arch, Libc $libc, string $expected): void + { + $result = $this->download( + self::registryEntry('mago'), + 'carthage-software/mago', + '1.51.2', + self::MAGO_ASSETS, + OperatingSystem::Linux, + $arch, + $libc, + ); + + Assert::same($result->file->getFilename(), $expected); + } + #[BeforeTest] protected function setUp(): void { @@ -174,19 +224,38 @@ private static function registryEntry(string $alias): Software private function downloadBun(Software $software, OperatingSystem $os, Architecture $arch): DownloadResult { - $repository = new RepositoryStub('oven-sh/bun'); - $release = new ReleaseStub($repository, 'Bun v1.4.2', Version::fromVersionString('v1.4.2'), tag: 'bun-v1.4.2'); + return $this->download($software, 'oven-sh/bun', 'bun-v1.4.2', self::BUN_ASSETS, $os, $arch, Libc::Gnu); + } + + /** + * Runs the downloader against one release with the given assets. + * + * @param non-empty-string $repositoryName + * @param non-empty-string $tag + * @param list $assets Asset names in the order the API lists them. + */ + private function download( + Software $software, + string $repositoryName, + string $tag, + array $assets, + OperatingSystem $os, + Architecture $arch, + Libc $libc, + ): DownloadResult { + $repository = new RepositoryStub($repositoryName); + $release = new ReleaseStub($repository, $tag, Version::fromVersionString(\preg_replace('/^[a-z]+-/', '', $tag)), tag: $tag); $release->setAssets(\array_map( static fn(string $name): AssetStub => new AssetStub( $release, $name, - 'https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/' . $name, + "https://github.com/{$repositoryName}/releases/download/{$tag}/{$name}", OperatingSystem::tryFromBuildName($name), Architecture::tryFromBuildName($name), ), - self::BUN_ASSETS, + $assets, )); - $repository = new RepositoryStub('oven-sh/bun', ReleasesCollection::create([$release])); + $repository = new RepositoryStub($repositoryName, ReleasesCollection::create([$release])); $config = new DownloaderConfig(); $config->tmpDir = $this->tempDir; @@ -202,9 +271,9 @@ private function downloadBun(Software $software, OperatingSystem $os, Architectu stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: new RecordingRegistry(), - assetSelector: new AssetSelector($os, $arch, new ArchiveFactory()), + assetSelector: new AssetSelector($os, $arch, $libc, new ArchiveFactory()), ); - $task = $downloader->download($software, DownloadConfig::fromSoftwareId('bun'), static fn(): null => null); + $task = $downloader->download($software, DownloadConfig::fromSoftwareId($software->getId()), static fn(): null => null); /** @var DownloadResult */ return await(($task->handler)()); diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 49d4c273..79bcfbcf 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -7,6 +7,7 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\FileSystem\FS; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; @@ -427,7 +428,7 @@ private function download(array|SequenceRepositoryFactoryStub $repositories, str stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, - assetSelector: new AssetSelector(OperatingSystem::tryFromString('linux') ?? throw new \LogicException(), Architecture::tryFromString('amd64') ?? throw new \LogicException(), new ArchiveFactory()), + assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, Libc::Gnu, new ArchiveFactory()), ); $software = Software::fromArray([ @@ -476,7 +477,7 @@ private function makeDownloader(SequenceRepositoryFactoryStub $factory, Download stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, - assetSelector: new AssetSelector(OperatingSystem::tryFromString('linux') ?? throw new \LogicException(), Architecture::tryFromString('amd64') ?? throw new \LogicException(), new ArchiveFactory()), + assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, Libc::Gnu, new ArchiveFactory()), ); } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index e9e3a614..966e10be 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -6,6 +6,7 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Config\Schema\Action\Type; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; @@ -25,6 +26,7 @@ #[Covers(FormatRule::class)] #[Covers(OperatingSystemRule::class)] #[Covers(ArchitectureRule::class)] +#[Covers(LibcRule::class)] #[Covers(ArchiveRule::class)] final class AssetSelectorTest { @@ -103,6 +105,38 @@ public function aGradualSelectionPutsOtherPlatformsAfterTheHostOne(): void ]); } + #[Test] + public function aGlibcHostPrefersGlibcBuildsAndKeepsMuslOnesAsAFallback(): void + { + $names = self::select(['tool-x86_64-unknown-linux-musl.tar.gz', 'tool-x86_64-unknown-linux-gnu.tar.gz']); + + Assert::same($names, ['tool-x86_64-unknown-linux-gnu.tar.gz', 'tool-x86_64-unknown-linux-musl.tar.gz']); + } + + #[Test] + public function aMuslHostPrefersMuslBuilds(): void + { + $names = self::select(['tool-linux-amd64.tar.gz', 'tool-linux-amd64-musl.tar.gz'], libc: Libc::Musl); + + Assert::same($names, ['tool-linux-amd64-musl.tar.gz', 'tool-linux-amd64.tar.gz']); + } + + #[Test] + public function aMuslOnlyReleaseIsSelectedOnAGlibcHost(): void + { + $names = self::select(['tool-x86_64-unknown-linux-musl.tar.gz', 'tool-aarch64-unknown-linux-musl.tar.gz']); + + Assert::same($names, ['tool-x86_64-unknown-linux-musl.tar.gz']); + } + + #[Test] + public function theLibcIsWeighedAfterThePlatform(): void + { + $names = self::select(['tool-linux-arm64.tar.gz', 'tool-linux-amd64-musl.tar.gz'], strict: false); + + Assert::same($names, ['tool-linux-amd64-musl.tar.gz', 'tool-linux-arm64.tar.gz']); + } + #[Test] public function archivesComeBeforeOtherFiles(): void { @@ -123,8 +157,9 @@ private static function select( bool $strict = true, OperatingSystem $os = OperatingSystem::Linux, Architecture $arch = Architecture::X86_64, + Libc $libc = Libc::Gnu, ): array { - $selection = (new AssetSelector($os, $arch, new ArchiveFactory())) + $selection = (new AssetSelector($os, $arch, $libc, new ArchiveFactory())) ->select(NamedAssets::create(...$assets), $pattern, $type, $strict); return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $selection->assets()); From 426fe1b1f942ee85540622f1a16212ee54902b27 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 5 Oct 2026 22:54:00 +0400 Subject: [PATCH 04/14] feat(downloader): prefer the plain build over its variants feat(registry): select Bun assets with a broad pattern Among assets that fit the host, the one with the fewest name tokens beyond the platform, version and extension wins, so `-baseline`, `-profile` and `-debug` builds become fallbacks without a list of variant names. This also stops TigerBeetle from installing its debug build. Closes #134 Assisted-By: Claude Opus 5.5 --- resources/software.json | 2 +- .../references/registry-entry.md | 5 +- .../references/troubleshooting.md | 4 +- src/Module/Downloader/Downloader.php | 8 +- .../Internal/AssetSelection/AssetName.php | 35 +++++++ .../Internal/AssetSelection/AssetSelector.php | 2 + .../Internal/AssetSelection/Candidate.php | 9 +- .../AssetSelection/Rule/ArchitectureRule.php | 12 +-- .../AssetSelection/Rule/ArchiveRule.php | 2 +- .../AssetSelection/Rule/ExtrasRule.php | 27 ++++++ .../Internal/AssetSelection/Rule/LibcRule.php | 1 + .../Rule/OperatingSystemRule.php | 1 + .../Internal/AssetSelection/Selection.php | 13 ++- .../Module/Downloader/AssetSelectionTest.php | 92 +++++++++++++------ .../Internal/AssetSelection/AssetNameTest.php | 46 ++++++++++ .../AssetSelection/AssetSelectorTest.php | 25 +++++ .../Internal/AssetSelection/SelectionTest.php | 18 +++- 17 files changed, 250 insertions(+), 52 deletions(-) create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRule.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/AssetNameTest.php diff --git a/resources/software.json b/resources/software.json index 0a58283e..f6ef13aa 100644 --- a/resources/software.json +++ b/resources/software.json @@ -287,7 +287,7 @@ "type": "github", "uri": "oven-sh/bun", "tag-prefix": "bun-", - "asset-pattern": "/^bun-[a-z]+-(?:x64|aarch64)\\.zip$/" + "asset-pattern": "/^bun-.*/" } ], "binary": { diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index d8eac32c..9726ac49 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,12 +22,15 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are never picked on Linux. + Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): | Kind | Tokens | |---|---| -| OS | `windows`, `linux`, `darwin`, `macos`, `alpine`, `bsd`, `freebsd`, `win32`, `win64` | +| OS | `windows`, `linux`, `darwin`, `macos`, `alpine`, `bsd`, `freebsd`, `win32`, `win64`, `android` (wins over `linux`) | | Arch | `amd64`, `arm64`, `aarch64`, `x86_64`, `x64`, `win64` | +| Libc | `musl`, `musleabi*`, `alpine`; `gnu`, `gnueabi*`, `glibc`; none means glibc | | Situation | Pattern | |---|---| diff --git a/skills/dload-fetch-tool/references/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md index 6cd7ca44..3d8cc34e 100644 --- a/skills/dload-fetch-tool/references/troubleshooting.md +++ b/skills/dload-fetch-tool/references/troubleshooting.md @@ -34,7 +34,7 @@ curl -s "https://api.github.com/repos///releases/tags/" \ After filtering, dload picks the host's variant with: -- OS: `/(?:\b|_)(windows|linux|darwin|macos|alpine|bsd|freebsd|win32|win64)(?:\b|_)/i` +- OS: `/(?:\b|_)(windows|linux|darwin|macos|alpine|bsd|freebsd|win32|win64)(?:\b|_)/i`; an `android` token makes it Android, a bare `musl` makes it Linux - Arch: `/(?:\b|_)(amd64|arm64|aarch64|x86_64|x64|win64)(?:\b|_)/i` A candidate that matches neither is discarded. Common offenders: @@ -44,6 +44,8 @@ A candidate that matches neither is discarded. Common offenders: Compare with the host: `php -r "echo PHP_OS_FAMILY, ' / ', php_uname('m'), PHP_EOL;"`. +Several candidates left: dload tries them best first — host libc, then the fewest extra name tokens, then archives, then the release order. Run with `-vvv` to see the rank of every asset (`Asset ... ranked: os=0 arch=0 libc=0 extras=1 archive=0`); a wrong pick usually means the wanted build carries an extra token the others lack. + ## Stage 4 — binary extraction The chosen asset is unpacked into `temp-dir`, then `binary.pattern` selects the executable. diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index d1c25032..ce81830e 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -316,9 +316,13 @@ private function processRelease(DownloadContext $context): \Closure foreach ($selection->sorted() as $candidate) { $this->logger->debug( - 'Asset `%s` ranked [%s].', + 'Asset `%s` ranked: %s.', $candidate->asset->getName(), - \implode(', ', $candidate->ranks), + \implode(' ', \array_map( + static fn(string $key, int $rank): string => "{$key}={$rank}", + \array_keys($candidate->ranks), + $candidate->ranks, + )), ); } diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetName.php b/src/Module/Downloader/Internal/AssetSelection/AssetName.php index d970c750..0ca6cd3d 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetName.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetName.php @@ -14,14 +14,49 @@ */ final class AssetName { + /** + * File extensions, archive layers and companion files like checksums and signatures. + */ + private const EXTENSION = '/\.(?:tar|gz|tgz|xz|txz|bz2|tbz2?|zst|zip|7z|rar|exe|msi|phar|deb|rpm|apk|dmg|pkg|appimage|sha\d*(?:sum)?|md5|asc|sig|pem|txt|json)$/'; + + /** + * Platform tokens: OS, architecture, libc and the vendor part of target triples. + */ + private const PLATFORM = '/(? $extras Name tokens that are not a platform, a version or an extension, + * like `profile` or `debug`. The tool name is one of them too. + */ private function __construct( public readonly ?Libc $libc, + public readonly array $extras, ) {} public static function fromString(string $name): self { return new self( libc: Libc::tryFromBuildName($name), + extras: self::extras($name), ); } + + /** + * @return list + */ + private static function extras(string $name): array + { + $name = \strtolower($name); + do { + $name = (string) \preg_replace(self::EXTENSION, '', $name, count: $count); + } while ($count > 0); + + /** @var list|false $tokens */ + $tokens = \preg_split('/[-_.\s]+/', (string) \preg_replace(self::PLATFORM, '', $name), flags: \PREG_SPLIT_NO_EMPTY); + + return \array_values(\array_filter( + $tokens === false ? [] : $tokens, + static fn(string $token): bool => \preg_match('/^v?\d+$/', $token) !== 1, + )); + } } diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index 546c529b..7c525052 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -12,6 +12,7 @@ use Internal\DLoad\Module\Config\Schema\Action\Type; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchiveRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ExtrasRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\FormatRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\NamePatternRule; @@ -52,6 +53,7 @@ public function __construct( new OperatingSystemRule($operatingSystem), new ArchitectureRule($architecture), new LibcRule($libc), + new ExtrasRule(), new ArchiveRule($archiveFactory), )->with(static fn(Selection $selection): Selection => $selection, 'select'); $this->pipeline = $pipeline; diff --git a/src/Module/Downloader/Internal/AssetSelection/Candidate.php b/src/Module/Downloader/Internal/AssetSelection/Candidate.php index 767d7852..3a13c7d9 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Candidate.php +++ b/src/Module/Downloader/Internal/AssetSelection/Candidate.php @@ -16,7 +16,7 @@ final class Candidate { /** * @param int<0, max> $position Position of the asset in the release, the last tie-breaker. - * @param list $ranks One key per ranking rule in the pipeline order; lower is better. + * @param array $ranks One key per ranking rule in the pipeline order; lower is better. */ public function __construct( public readonly AssetInterface $asset, @@ -40,8 +40,11 @@ public function hasExtension(array $extensions): bool return false; } - public function withRank(int $rank): self + /** + * @param non-empty-string $key Name of the rank, unique within the selection. + */ + public function withRank(string $key, int $rank): self { - return new self($this->asset, $this->name, $this->position, [...$this->ranks, $rank]); + return new self($this->asset, $this->name, $this->position, [...$this->ranks, $key => $rank]); } } diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php index 518dff61..40f031a6 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php @@ -10,8 +10,9 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Selection; /** - * Keeps assets built for the host architecture: removes the others in a strict selection, - * ranks them lower otherwise. + * Prefers assets built for the host architecture. + * + * A strict selection removes the others; otherwise they are ranked last. * * @internal * @psalm-internal Internal\DLoad\Module\Downloader @@ -25,11 +26,8 @@ public function __construct( public function select(Selection $selection, callable $next): Selection { $fits = fn(Candidate $candidate): bool => $candidate->asset->getArchitecture() === $this->architecture; + $selection->strict and $selection = $selection->remove(static fn(Candidate $candidate): bool => !$fits($candidate)); - return $next( - $selection->strict - ? $selection->remove(static fn(Candidate $candidate): bool => !$fits($candidate)) - : $selection->prefer($fits), - ); + return $next($selection->prefer('arch', $fits)); } } diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php index 78a7b065..f8a4d730 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php @@ -25,6 +25,6 @@ public function select(Selection $selection, callable $next): Selection { $extensions = $this->archiveFactory->getSupportedExtensions(); - return $next($selection->prefer(static fn(Candidate $candidate): bool => $candidate->hasExtension($extensions))); + return $next($selection->prefer('archive', static fn(Candidate $candidate): bool => $candidate->hasExtension($extensions))); } } diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRule.php new file mode 100644 index 00000000..fe9e7cad --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRule.php @@ -0,0 +1,27 @@ +rank('extras', static fn(Candidate $candidate): int => \count($candidate->name->extras))); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php index cff387d0..2e730cac 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php @@ -27,6 +27,7 @@ public function __construct( public function select(Selection $selection, callable $next): Selection { return $next($selection->prefer( + 'libc', fn(Candidate $candidate): bool => ($candidate->name->libc ?? Libc::Gnu) === $this->libc, )); } diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php index be2a11e1..6e36c8b3 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php @@ -30,6 +30,7 @@ public function select(Selection $selection, callable $next): Selection ); return $next($selection->rank( + 'os', fn(Candidate $candidate): int => $this->rank($candidate->asset->getOperatingSystem()) ?? 2, )); } diff --git a/src/Module/Downloader/Internal/AssetSelection/Selection.php b/src/Module/Downloader/Internal/AssetSelection/Selection.php index 91a3b403..6bc105a3 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Selection.php +++ b/src/Module/Downloader/Internal/AssetSelection/Selection.php @@ -65,12 +65,13 @@ public function remove(\Closure $predicate): self /** * Gives every candidate a rank; lower is better. * + * @param non-empty-string $key Name of the rank, unique within the selection. * @param \Closure(Candidate): int $rank */ - public function rank(\Closure $rank): self + public function rank(string $key, \Closure $rank): self { return $this->withCandidates(\array_map( - static fn(Candidate $candidate): Candidate => $candidate->withRank($rank($candidate)), + static fn(Candidate $candidate): Candidate => $candidate->withRank($key, $rank($candidate)), $this->candidates, )); } @@ -78,11 +79,12 @@ public function rank(\Closure $rank): self /** * Moves the candidates matching the predicate ahead of the others. * + * @param non-empty-string $key Name of the rank, unique within the selection. * @param \Closure(Candidate): bool $predicate */ - public function prefer(\Closure $predicate): self + public function prefer(string $key, \Closure $predicate): self { - return $this->rank(static fn(Candidate $candidate): int => $predicate($candidate) ? 0 : 1); + return $this->rank($key, static fn(Candidate $candidate): int => $predicate($candidate) ? 0 : 1); } public function isEmpty(): bool @@ -100,7 +102,8 @@ public function sorted(): array $candidates = $this->candidates; \usort( $candidates, - static fn(Candidate $a, Candidate $b): int => [...$a->ranks, $a->position] <=> [...$b->ranks, $b->position], + static fn(Candidate $a, Candidate $b): int => [...\array_values($a->ranks), $a->position] + <=> [...\array_values($b->ranks), $b->position], ); return $candidates; diff --git a/tests/Unit/Module/Downloader/AssetSelectionTest.php b/tests/Unit/Module/Downloader/AssetSelectionTest.php index 7cd2c0d0..8356c2d6 100644 --- a/tests/Unit/Module/Downloader/AssetSelectionTest.php +++ b/tests/Unit/Module/Downloader/AssetSelectionTest.php @@ -28,7 +28,6 @@ use Internal\Path; use Testo\Assert; use Testo\Codecov\Covers; -use Testo\Core\Exception\SkipTest; use Testo\Data\DataProvider; use Testo\Lifecycle\AfterTest; use Testo\Lifecycle\BeforeTest; @@ -110,6 +109,22 @@ final class AssetSelectionTest 'source-code.zip', ]; + /** + * Assets of the TigerBeetle 0.17.9 release: every build has a `-debug` twin listed first. + */ + private const TIGERBEETLE_ASSETS = [ + 'tigerbeetle-aarch64-linux-debug.zip', + 'tigerbeetle-aarch64-linux.zip', + 'tigerbeetle-universal-macos-debug.zip', + 'tigerbeetle-universal-macos.zip', + 'tigerbeetle-x86_64-linux-debug.zip', + 'tigerbeetle-x86_64-linux.zip', + 'tigerbeetle-x86_64-windows-debug.zip', + 'tigerbeetle-x86_64-windows.zip', + 'vortex-driver-zig-aarch64-linux.zip', + 'vortex-driver-zig-x86_64-linux.zip', + ]; + private string $tempDir; public static function provideBunHosts(): \Generator @@ -124,6 +139,27 @@ public static function provideBunHosts(): \Generator yield 'FreeBSD arm64' => [OperatingSystem::BSD, Architecture::ARM_64, 'bun-freebsd-aarch64.zip']; } + public static function provideBunMuslHosts(): \Generator + { + yield 'Linux x64' => [Architecture::X86_64, 'bun-linux-x64-musl.zip']; + yield 'Linux arm64' => [Architecture::ARM_64, 'bun-linux-aarch64-musl.zip']; + } + + public static function provideTigerBeetleHosts(): \Generator + { + yield 'Linux x64' => [OperatingSystem::Linux, Architecture::X86_64, 'tigerbeetle-x86_64-linux.zip']; + yield 'Linux arm64' => [OperatingSystem::Linux, Architecture::ARM_64, 'tigerbeetle-aarch64-linux.zip']; + yield 'Windows x64' => [OperatingSystem::Windows, Architecture::X86_64, 'tigerbeetle-x86_64-windows.zip']; + } + + public static function provideMagoHosts(): \Generator + { + yield 'Linux x64 glibc' => [Architecture::X86_64, Libc::Gnu, 'mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz']; + yield 'Linux x64 musl' => [Architecture::X86_64, Libc::Musl, 'mago-1.51.2-x86_64-unknown-linux-musl.tar.gz']; + yield 'Linux arm64 glibc' => [Architecture::ARM_64, Libc::Gnu, 'mago-1.51.2-aarch64-unknown-linux-gnu.tar.gz']; + yield 'Linux arm64 musl' => [Architecture::ARM_64, Libc::Musl, 'mago-1.51.2-aarch64-unknown-linux-musl.tar.gz']; + } + #[DataProvider('provideBunHosts')] #[Test] public function bunRegistryEntrySelectsThePlainBuild( @@ -136,41 +172,43 @@ public function bunRegistryEntrySelectsThePlainBuild( Assert::same($result->file->getFilename(), $expected); } - /** - * The target of variant ranking: the plain build wins without a pattern that spells out - * the asset shape of one tool. - */ - #[DataProvider('provideBunHosts')] + #[DataProvider('provideBunMuslHosts')] + #[Test] + public function bunMuslBuildIsSelectedOnAMuslHost(Architecture $arch, string $expected): void + { + $result = $this->download( + self::registryEntry('bun'), + 'oven-sh/bun', + 'bun-v1.4.2', + self::BUN_ASSETS, + OperatingSystem::Linux, + $arch, + Libc::Musl, + ); + + Assert::same($result->file->getFilename(), $expected); + } + + #[DataProvider('provideTigerBeetleHosts')] #[Test] - public function bunPlainBuildIsSelectedWithABroadAssetPattern( + public function tigerBeetleReleaseBuildIsSelectedOverTheDebugOne( OperatingSystem $os, Architecture $arch, string $expected, ): void { - $software = Software::fromArray([ - 'name' => 'Bun', - 'alias' => 'bun', - 'repositories' => [['type' => 'github', 'uri' => 'oven-sh/bun', 'asset-pattern' => '/^bun-.*/']], - 'binary' => ['name' => 'bun'], - ]); - - $result = $this->downloadBun($software, $os, $arch); + $result = $this->download( + self::registryEntry('tigerbeetle'), + 'tigerbeetle/tigerbeetle', + '0.17.9', + self::TIGERBEETLE_ASSETS, + $os, + $arch, + Libc::Gnu, + ); - $result->file->getFilename() === $expected or throw new SkipTest(\sprintf( - 'Picks `%s`: asset variants are not ranked yet, see https://github.com/php-internal/dload/issues/134', - $result->file->getFilename(), - )); Assert::same($result->file->getFilename(), $expected); } - public static function provideMagoHosts(): \Generator - { - yield 'Linux x64 glibc' => [Architecture::X86_64, Libc::Gnu, 'mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz']; - yield 'Linux x64 musl' => [Architecture::X86_64, Libc::Musl, 'mago-1.51.2-x86_64-unknown-linux-musl.tar.gz']; - yield 'Linux arm64 glibc' => [Architecture::ARM_64, Libc::Gnu, 'mago-1.51.2-aarch64-unknown-linux-gnu.tar.gz']; - yield 'Linux arm64 musl' => [Architecture::ARM_64, Libc::Musl, 'mago-1.51.2-aarch64-unknown-linux-musl.tar.gz']; - } - #[DataProvider('provideMagoHosts')] #[Test] public function magoBuildForTheHostLibcIsSelected(Architecture $arch, Libc $libc, string $expected): void diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetNameTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetNameTest.php new file mode 100644 index 00000000..19e067fd --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetNameTest.php @@ -0,0 +1,46 @@ + ['bun-linux-x64.zip', ['bun']]; + yield 'variant' => ['bun-linux-x64-baseline-profile.zip', ['bun', 'baseline', 'profile']]; + yield 'debug build' => ['tigerbeetle-x86_64-linux-debug.zip', ['tigerbeetle', 'debug']]; + yield 'target triple' => ['mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz', ['mago']]; + yield 'underscores' => ['temporal_cli_1.1.0_linux_amd64.tar.gz', ['temporal', 'cli']]; + yield 'version with v' => ['tool-v1.4.2-darwin-arm64.tar.gz', ['tool']]; + yield 'checksum' => ['tool-linux-amd64.tar.gz.sha256sum', ['tool']]; + yield 'unknown extension' => ['deno-x86_64-unknown-linux-gnu.from-2.9.6.bsdiff', ['deno', 'from', 'bsdiff']]; + yield 'no platform' => ['tool.phar', ['tool']]; + } + + /** + * @param list $expected + */ + #[DataProvider('provideExtras')] + #[Test] + public function extrasAreTheTokensLeftAfterThePlatformVersionAndExtension(string $name, array $expected): void + { + Assert::same(AssetName::fromString($name)->extras, $expected); + } + + #[Test] + public function libcIsReadFromTheName(): void + { + Assert::same(AssetName::fromString('bun-linux-x64-musl.zip')->libc, Libc::Musl); + Assert::null(AssetName::fromString('bun-linux-x64.zip')->libc); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 966e10be..5056b27b 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -27,6 +27,7 @@ #[Covers(OperatingSystemRule::class)] #[Covers(ArchitectureRule::class)] #[Covers(LibcRule::class)] +#[Covers(ExtrasRule::class)] #[Covers(ArchiveRule::class)] final class AssetSelectorTest { @@ -137,6 +138,30 @@ public function theLibcIsWeighedAfterThePlatform(): void Assert::same($names, ['tool-linux-amd64-musl.tar.gz', 'tool-linux-arm64.tar.gz']); } + #[Test] + public function thePlainBuildComesBeforeItsVariants(): void + { + $names = self::select([ + 'tool-linux-amd64-baseline-profile.zip', + 'tool-linux-amd64-baseline.zip', + 'tool-linux-amd64.zip', + ]); + + Assert::same($names, [ + 'tool-linux-amd64.zip', + 'tool-linux-amd64-baseline.zip', + 'tool-linux-amd64-baseline-profile.zip', + ]); + } + + #[Test] + public function aPlainBinaryComesBeforeAnArchivedVariant(): void + { + $names = self::select(['tool-linux-amd64-debug.tar.gz', 'tool-linux-amd64']); + + Assert::same($names, ['tool-linux-amd64', 'tool-linux-amd64-debug.tar.gz']); + } + #[Test] public function archivesComeBeforeOtherFiles(): void { diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php index 3a7dd27c..76dd730e 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php @@ -35,8 +35,8 @@ public function removedCandidatesAreGone(): void public function anEarlierRankOutweighsALaterOne(): void { $selection = self::selection('a-slow', 'b-fast', 'c-fast') - ->prefer(static fn(Candidate $candidate): bool => $candidate->asset->getName() !== 'b-fast') - ->prefer(static fn(Candidate $candidate): bool => \str_ends_with($candidate->asset->getName(), 'fast')); + ->prefer('first', static fn(Candidate $candidate): bool => $candidate->asset->getName() !== 'b-fast') + ->prefer('second', static fn(Candidate $candidate): bool => \str_ends_with($candidate->asset->getName(), 'fast')); Assert::same(self::names($selection), ['c-fast', 'a-slow', 'b-fast']); } @@ -45,7 +45,7 @@ public function anEarlierRankOutweighsALaterOne(): void public function aLaterRankOnlyOrdersCandidatesEqualByTheEarlierOnes(): void { $selection = self::selection('aaa', 'b', 'cc') - ->rank(static fn(Candidate $candidate): int => \strlen($candidate->asset->getName())); + ->rank('length', static fn(Candidate $candidate): int => \strlen($candidate->asset->getName())); Assert::same(self::names($selection), ['b', 'cc', 'aaa']); } @@ -53,11 +53,21 @@ public function aLaterRankOnlyOrdersCandidatesEqualByTheEarlierOnes(): void #[Test] public function equallyRankedCandidatesKeepTheReleaseOrder(): void { - $selection = self::selection('b', 'a', 'c')->rank(static fn(): int => 1); + $selection = self::selection('b', 'a', 'c')->rank('same', static fn(): int => 1); Assert::same(self::names($selection), ['b', 'a', 'c']); } + #[Test] + public function ranksAreKeptUnderTheirNames(): void + { + $selection = self::selection('a') + ->rank('os', static fn(): int => 2) + ->prefer('libc', static fn(): bool => true); + + Assert::same($selection->sorted()[0]->ranks, ['os' => 2, 'libc' => 0]); + } + #[Test] public function aSelectionWithoutCandidatesIsEmpty(): void { From 08f1f67fc7bf1ff7ff7fa1435cb6b0e38da5c124 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 00:13:48 +0400 Subject: [PATCH 05/14] test(downloader): cover each asset selection rule on its own Assisted-By: Claude Opus 5.5 --- .../Rule/ArchitectureRuleTest.php | 42 ++++++++++++ .../AssetSelection/Rule/ArchiveRuleTest.php | 34 ++++++++++ .../AssetSelection/Rule/ExtrasRuleTest.php | 42 ++++++++++++ .../AssetSelection/Rule/FormatRuleTest.php | 63 ++++++++++++++++++ .../AssetSelection/Rule/LibcRuleTest.php | 54 +++++++++++++++ .../Rule/NamePatternRuleTest.php | 43 ++++++++++++ .../Rule/OperatingSystemRuleTest.php | 55 ++++++++++++++++ .../AssetSelection/Stub/RuleRunner.php | 66 +++++++++++++++++++ 8 files changed, 399 insertions(+) create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRuleTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRuleTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/FormatRuleTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRuleTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRuleTest.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/RuleRunner.php diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php new file mode 100644 index 00000000..b05c751f --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php @@ -0,0 +1,42 @@ + 0]); + } + + #[Test] + public function aGradualSelectionRanksOtherArchitecturesLast(): void + { + $selection = RuleRunner::run(new ArchitectureRule(Architecture::ARM_64), self::ASSETS, strict: false); + + Assert::same(RuleRunner::ranks($selection, 'arch'), [ + 'tool-linux-x86_64.zip' => 1, + 'tool-linux-aarch64.zip' => 0, + 'tool-linux.zip' => 1, + ]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRuleTest.php new file mode 100644 index 00000000..f94fe6fb --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRuleTest.php @@ -0,0 +1,34 @@ + 1, + 'tool-linux-amd64.deb' => 1, + 'tool-linux-amd64.tar.gz' => 0, + 'tool-linux-amd64.zip' => 0, + ]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRuleTest.php new file mode 100644 index 00000000..a45e9a7e --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRuleTest.php @@ -0,0 +1,42 @@ + 3, + 'bun-linux-x64-baseline.zip' => 2, + 'bun-linux-x64.zip' => 1, + ]); + } + + #[Test] + public function aChecksumCountsAsItsAsset(): void + { + $selection = RuleRunner::run(new ExtrasRule(), ['tool-linux-amd64.tar.gz', 'tool-linux-amd64.tar.gz.sha256']); + + Assert::same(RuleRunner::ranks($selection, 'extras'), [ + 'tool-linux-amd64.tar.gz' => 1, + 'tool-linux-amd64.tar.gz.sha256' => 1, + ]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/FormatRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/FormatRuleTest.php new file mode 100644 index 00000000..0da5e141 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/FormatRuleTest.php @@ -0,0 +1,63 @@ + 0, + 'tool-x86_64-unknown-linux-musl.tar.gz' => 1, + 'tool-linux-amd64.tar.gz' => 0, + ]); + } + + #[Test] + public function aMuslHostRanksMuslBuildsFirst(): void + { + $selection = RuleRunner::run(new LibcRule(Libc::Musl), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-x86_64-unknown-linux-gnu.tar.gz' => 1, + 'tool-x86_64-unknown-linux-musl.tar.gz' => 0, + 'tool-linux-amd64.tar.gz' => 1, + ]); + } + + #[Test] + public function nothingIsRemoved(): void + { + $selection = RuleRunner::run(new LibcRule(Libc::Gnu), self::ASSETS); + + Assert::same(RuleRunner::names($selection), self::ASSETS); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRuleTest.php new file mode 100644 index 00000000..c7ef60c5 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRuleTest.php @@ -0,0 +1,43 @@ +isEmpty()); + } + + #[Test] + public function nothingIsRanked(): void + { + $selection = RuleRunner::run(new NamePatternRule(), ['tool-linux-amd64.zip']); + + Assert::same($selection->candidates[0]->ranks, []); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRuleTest.php new file mode 100644 index 00000000..5ea45f1f --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRuleTest.php @@ -0,0 +1,55 @@ + 0]); + } + + #[Test] + public function aGradualSelectionRanksOtherOsesLast(): void + { + $selection = RuleRunner::run(new OperatingSystemRule(OperatingSystem::Linux), self::ASSETS, strict: false); + + Assert::same(RuleRunner::ranks($selection, 'os'), [ + 'tool-linux-amd64.zip' => 0, + 'tool-linux-amd64-android.zip' => 2, + 'tool-darwin-amd64.zip' => 2, + 'tool-amd64.zip' => 2, + ]); + } + + #[Test] + public function androidKeepsLinuxBuildsAsAFallback(): void + { + $selection = RuleRunner::run(new OperatingSystemRule(OperatingSystem::Android), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'os'), [ + 'tool-linux-amd64.zip' => 1, + 'tool-linux-amd64-android.zip' => 0, + ]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/RuleRunner.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/RuleRunner.php new file mode 100644 index 00000000..9035efb1 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/RuleRunner.php @@ -0,0 +1,66 @@ + $assets Asset names in release order. + * @param non-empty-string $pattern + */ + public static function run( + AssetRule $rule, + array $assets, + bool $strict = true, + ?Type $type = null, + string $pattern = '/.*/', + ): Selection { + $calls = 0; + $result = $rule->select( + Selection::create(NamedAssets::create(...$assets), $pattern, $type, $strict), + static function (Selection $selection) use (&$calls): Selection { + ++$calls; + return $selection; + }, + ); + + $calls === 1 or throw new \LogicException(\sprintf('The rule called `$next` %d times.', $calls)); + return $result; + } + + /** + * Names of the candidates left, in release order. + * + * @return list + */ + public static function names(Selection $selection): array + { + return \array_map(static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->candidates); + } + + /** + * The rank the rule gave each candidate under the key, by asset name. + * + * @param non-empty-string $key + * @return array + */ + public static function ranks(Selection $selection, string $key): array + { + $ranks = []; + foreach ($selection->candidates as $candidate) { + $ranks[$candidate->asset->getName()] = $candidate->ranks[$key] ?? null; + } + + return $ranks; + } +} From 913b4ca24d6b202f2a21ae9acc591c8f92487cce Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 09:46:12 +0400 Subject: [PATCH 06/14] test(downloader): cover host detection and fix coverage metadata fix(downloader): do not detect musl when the loader lookup fails Host detection takes the OS family and the probed facts as arguments, so the Android and musl branches are tested on any host. `glob()` reports an error as `false`, which the old check took for a found loader. Assisted-By: Claude Opus 5.5 --- src/Module/Common/Libc.php | 16 ++++++++++--- src/Module/Common/OperatingSystem.php | 18 ++++++++++---- tests/Unit/Module/Common/LibcTest.php | 24 +++++++++++++++++++ .../Module/Common/OperatingSystemTest.php | 14 +++++++++++ .../AssetSelection/AssetSelectorTest.php | 2 ++ 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/Module/Common/Libc.php b/src/Module/Common/Libc.php index 5bac92c6..0f0c728d 100644 --- a/src/Module/Common/Libc.php +++ b/src/Module/Common/Libc.php @@ -37,11 +37,21 @@ public static function create(Build $config): self } public static function fromGlobals(): self + { + return self::detect(\PHP_OS_FAMILY, '/'); + } + + /** + * @param string $osFamily OS family in terms of {@see PHP_OS_FAMILY}. + * @param non-empty-string $root Root of the file system to look for the musl loader in. + */ + public static function detect(string $osFamily, string $root): self { // The musl dynamic loader exists on musl systems only, and checking it runs nothing - return \PHP_OS_FAMILY === 'Linux' && \glob('/lib/ld-musl-*.so.1') !== [] - ? self::Musl - : self::Gnu; + $loaders = \glob(\rtrim($root, '/') . '/lib/ld-musl-*.so.1'); + + // `glob()` reports an error as `false`, which must not count as a found loader + return $osFamily === 'Linux' && \is_array($loaders) && $loaders !== [] ? self::Musl : self::Gnu; } public static function tryFromBuildName(string $name): ?self diff --git a/src/Module/Common/OperatingSystem.php b/src/Module/Common/OperatingSystem.php index fc20f411..c7f1e5bc 100644 --- a/src/Module/Common/OperatingSystem.php +++ b/src/Module/Common/OperatingSystem.php @@ -41,12 +41,22 @@ public static function create(Build $config): static public static function fromGlobals(): self { - $os = self::tryFromString(\PHP_OS_FAMILY) ?? throw new \OutOfRangeException( - \sprintf(self::ERROR_UNKNOWN_OS, \PHP_OS_FAMILY), + // The variable is set by the Android runtime and Termux + return self::fromHost(\PHP_OS_FAMILY, \getenv('ANDROID_ROOT') !== false); + } + + /** + * @param string $osFamily OS family in terms of {@see PHP_OS_FAMILY}. + * @param bool $androidRuntime Whether the Android runtime is present. + */ + public static function fromHost(string $osFamily, bool $androidRuntime): self + { + $os = self::tryFromString($osFamily) ?? throw new \OutOfRangeException( + \sprintf(self::ERROR_UNKNOWN_OS, $osFamily), ); - // PHP reports Android as Linux; the variable is set by the Android runtime and Termux - return $os === self::Linux && \getenv('ANDROID_ROOT') !== false ? self::Android : $os; + // PHP reports Android as Linux + return $os === self::Linux && $androidRuntime ? self::Android : $os; } public static function tryFromString(string $name): ?self diff --git a/tests/Unit/Module/Common/LibcTest.php b/tests/Unit/Module/Common/LibcTest.php index 51285bc7..55b0c830 100644 --- a/tests/Unit/Module/Common/LibcTest.php +++ b/tests/Unit/Module/Common/LibcTest.php @@ -28,6 +28,13 @@ public static function provideBuildNames(): iterable yield ['muslim-prayer-times-linux-amd64.zip', null]; } + public static function provideHosts(): iterable + { + yield 'Linux with the musl loader' => ['Linux', true, Libc::Musl]; + yield 'Linux without it' => ['Linux', false, Libc::Gnu]; + yield 'not Linux' => ['Darwin', true, Libc::Gnu]; + } + #[DataProvider('provideBuildNames')] #[Test] public function tryFromBuildName(string $name, ?Libc $expected): void @@ -43,4 +50,21 @@ public function aMuslOsOptionSelectsMusl(): void Assert::same(Libc::create($build), Libc::Musl); } + + #[DataProvider('provideHosts')] + #[Test] + public function muslIsDetectedByItsLoaderOnLinux(string $osFamily, bool $loader, Libc $expected): void + { + $root = \sys_get_temp_dir() . '/dload-libc-' . \bin2hex(\random_bytes(6)); + \mkdir($root . '/lib', recursive: true); + $loader and \touch($root . '/lib/ld-musl-x86_64.so.1'); + + try { + Assert::same(Libc::detect($osFamily, $root), $expected); + } finally { + $loader and \unlink($root . '/lib/ld-musl-x86_64.so.1'); + \rmdir($root . '/lib'); + \rmdir($root); + } + } } diff --git a/tests/Unit/Module/Common/OperatingSystemTest.php b/tests/Unit/Module/Common/OperatingSystemTest.php index 055537eb..73772bd8 100644 --- a/tests/Unit/Module/Common/OperatingSystemTest.php +++ b/tests/Unit/Module/Common/OperatingSystemTest.php @@ -26,10 +26,24 @@ public static function provideBuildNames(): iterable yield ['tool-aarch64-linux-android.tar.gz', OperatingSystem::Android]; } + public static function provideHosts(): iterable + { + yield 'Linux with the Android runtime' => ['Linux', true, OperatingSystem::Android]; + yield 'Linux without it' => ['Linux', false, OperatingSystem::Linux]; + yield 'not Linux' => ['Darwin', true, OperatingSystem::Darwin]; + } + #[DataProvider('provideBuildNames')] #[Test] public function tryFromBuildName(string $name, ?OperatingSystem $expected): void { Assert::same(OperatingSystem::tryFromBuildName($name), $expected); } + + #[DataProvider('provideHosts')] + #[Test] + public function androidIsDetectedByItsRuntimeOnLinux(string $osFamily, bool $androidRuntime, OperatingSystem $expected): void + { + Assert::same(OperatingSystem::fromHost($osFamily, $androidRuntime), $expected); + } } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 5056b27b..360cddbc 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -12,7 +12,9 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchiveRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ExtrasRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\FormatRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\NamePatternRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\OperatingSystemRule; use Internal\DLoad\Module\Repository\AssetInterface; From 8003e2af6031b12d7cf407f3eefe92bbd7449404 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 10:55:13 +0400 Subject: [PATCH 07/14] refactor(downloader): read the host libc lazily in asset selection The libc rule takes the host libc from the container only when the remaining assets differ in libc, so selecting assets through the API does not probe the host for nothing. Assisted-By: Claude Opus 5.5 --- src/Module/Common/Libc.php | 8 +++-- .../Internal/AssetSelection/AssetSelector.php | 6 ++-- .../Internal/AssetSelection/Rule/LibcRule.php | 28 +++++++++++---- .../Module/Downloader/AssetSelectionTest.php | 3 +- .../Unit/Module/Downloader/DownloaderTest.php | 5 +-- .../AssetSelection/AssetSelectorTest.php | 3 +- .../AssetSelection/Rule/LibcRuleTest.php | 32 +++++++++++++++-- .../AssetSelection/Stub/LibcContainer.php | 34 +++++++++++++++++++ 8 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/LibcContainer.php diff --git a/src/Module/Common/Libc.php b/src/Module/Common/Libc.php index 0f0c728d..c9886b9e 100644 --- a/src/Module/Common/Libc.php +++ b/src/Module/Common/Libc.php @@ -14,7 +14,7 @@ * treated as {@see self::Gnu}, which keeps musl builds a fallback there. * * ```php - * // Recommended: Get from container (autowired with build config) + * // Recommended: Get from container (autowired with build config); detection runs once * $libc = $container->get(Libc::class); * * // Or read it from a build name @@ -47,11 +47,15 @@ public static function fromGlobals(): self */ public static function detect(string $osFamily, string $root): self { + if ($osFamily !== 'Linux') { + return self::Gnu; + } + // The musl dynamic loader exists on musl systems only, and checking it runs nothing $loaders = \glob(\rtrim($root, '/') . '/lib/ld-musl-*.so.1'); // `glob()` reports an error as `false`, which must not count as a found loader - return $osFamily === 'Linux' && \is_array($loaders) && $loaders !== [] ? self::Musl : self::Gnu; + return \is_array($loaders) && $loaders !== [] ? self::Musl : self::Gnu; } public static function tryFromBuildName(string $name): ?self diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index 7c525052..196f7902 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -6,7 +6,6 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; -use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Pipeline\Pipeline; use Internal\DLoad\Module\Config\Schema\Action\Type; @@ -18,6 +17,7 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\NamePatternRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\OperatingSystemRule; use Internal\DLoad\Module\Repository\AssetInterface; +use Psr\Container\ContainerInterface; /** * Orders the assets of a release from the best fit for the host to the worst. @@ -38,7 +38,7 @@ final class AssetSelector public function __construct( OperatingSystem $operatingSystem, Architecture $architecture, - Libc $libc, + ContainerInterface $container, ArchiveFactory $archiveFactory, ) { /** @@ -52,7 +52,7 @@ public function __construct( new FormatRule($archiveFactory), new OperatingSystemRule($operatingSystem), new ArchitectureRule($architecture), - new LibcRule($libc), + new LibcRule($container), new ExtrasRule(), new ArchiveRule($archiveFactory), )->with(static fn(Selection $selection): Selection => $selection, 'select'); diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php index 2e730cac..60c49edb 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php @@ -8,12 +8,14 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Candidate; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Selection; +use Psr\Container\ContainerInterface; /** * Prefers assets linked against the host libc. * * Ranks rather than removes: a static build runs whatever the libc, and some tools publish - * only a musl build for Linux. An asset that names no libc counts as a glibc one. + * only a musl build for Linux. An asset that names no libc counts as a glibc one. The host libc + * comes from the container only when the assets differ in libc: resolving it probes the file system. * * @internal * @psalm-internal Internal\DLoad\Module\Downloader @@ -21,14 +23,28 @@ final class LibcRule implements AssetRule { public function __construct( - private readonly Libc $libc, + private readonly ContainerInterface $container, ) {} public function select(Selection $selection, callable $next): Selection { - return $next($selection->prefer( - 'libc', - fn(Candidate $candidate): bool => ($candidate->name->libc ?? Libc::Gnu) === $this->libc, - )); + $libcs = []; + foreach ($selection->candidates as $candidate) { + $libcs[self::libc($candidate)->value] = true; + } + + // The host is probed only when there is a choice between libcs + if (\count($libcs) < 2) { + return $next($selection->rank('libc', static fn(): int => 0)); + } + + /** @var Libc $host */ + $host = $this->container->get(Libc::class); + return $next($selection->prefer('libc', static fn(Candidate $candidate): bool => self::libc($candidate) === $host)); + } + + private static function libc(Candidate $candidate): Libc + { + return $candidate->name->libc ?? Libc::Gnu; } } diff --git a/tests/Unit/Module/Downloader/AssetSelectionTest.php b/tests/Unit/Module/Downloader/AssetSelectionTest.php index 8356c2d6..4e60918f 100644 --- a/tests/Unit/Module/Downloader/AssetSelectionTest.php +++ b/tests/Unit/Module/Downloader/AssetSelectionTest.php @@ -26,6 +26,7 @@ use Internal\DLoad\Tests\Unit\Module\Repository\Stub\ReleaseStub; use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RepositoryStub; use Internal\Path; +use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; @@ -309,7 +310,7 @@ private function download( stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: new RecordingRegistry(), - assetSelector: new AssetSelector($os, $arch, $libc, new ArchiveFactory()), + assetSelector: new AssetSelector($os, $arch, new LibcContainer($libc), new ArchiveFactory()), ); $task = $downloader->download($software, DownloadConfig::fromSoftwareId($software->getId()), static fn(): null => null); diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 79bcfbcf..45b01680 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -33,6 +33,7 @@ use Internal\DLoad\Tests\Unit\Module\Repository\Stub\ReleaseStub; use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RepositoryStub; use Internal\Path; +use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; @@ -428,7 +429,7 @@ private function download(array|SequenceRepositoryFactoryStub $repositories, str stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, - assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, Libc::Gnu, new ArchiveFactory()), + assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, new LibcContainer(Libc::Gnu), new ArchiveFactory()), ); $software = Software::fromArray([ @@ -477,7 +478,7 @@ private function makeDownloader(SequenceRepositoryFactoryStub $factory, Download stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, - assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, Libc::Gnu, new ArchiveFactory()), + assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, new LibcContainer(Libc::Gnu), new ArchiveFactory()), ); } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 360cddbc..932f1fd9 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -19,6 +19,7 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\OperatingSystemRule; use Internal\DLoad\Module\Repository\AssetInterface; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\NamedAssets; +use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Test; @@ -186,7 +187,7 @@ private static function select( Architecture $arch = Architecture::X86_64, Libc $libc = Libc::Gnu, ): array { - $selection = (new AssetSelector($os, $arch, $libc, new ArchiveFactory())) + $selection = (new AssetSelector($os, $arch, new LibcContainer($libc), new ArchiveFactory())) ->select(NamedAssets::create(...$assets), $pattern, $type, $strict); return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $selection->assets()); diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php index e5291c36..c600b5f2 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php @@ -7,6 +7,7 @@ use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\RuleRunner; +use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Test; @@ -23,7 +24,7 @@ final class LibcRuleTest #[Test] public function aGlibcHostRanksMuslBuildsLast(): void { - $selection = RuleRunner::run(new LibcRule(Libc::Gnu), self::ASSETS); + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::Gnu)), self::ASSETS); Assert::same(RuleRunner::ranks($selection, 'libc'), [ 'tool-x86_64-unknown-linux-gnu.tar.gz' => 0, @@ -35,7 +36,7 @@ public function aGlibcHostRanksMuslBuildsLast(): void #[Test] public function aMuslHostRanksMuslBuildsFirst(): void { - $selection = RuleRunner::run(new LibcRule(Libc::Musl), self::ASSETS); + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::Musl)), self::ASSETS); Assert::same(RuleRunner::ranks($selection, 'libc'), [ 'tool-x86_64-unknown-linux-gnu.tar.gz' => 1, @@ -47,8 +48,33 @@ public function aMuslHostRanksMuslBuildsFirst(): void #[Test] public function nothingIsRemoved(): void { - $selection = RuleRunner::run(new LibcRule(Libc::Gnu), self::ASSETS); + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::Gnu)), self::ASSETS); Assert::same(RuleRunner::names($selection), self::ASSETS); } + + #[Test] + public function theHostIsNotProbedWithoutAChoiceOfLibc(): void + { + $container = new LibcContainer(Libc::Gnu); + + $selection = RuleRunner::run(new LibcRule($container), ['tool-linux-amd64.tar.gz', 'tool-x86_64-unknown-linux-gnu.tar.gz']); + + Assert::same($container->requests, 0); + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-linux-amd64.tar.gz' => 0, + 'tool-x86_64-unknown-linux-gnu.tar.gz' => 0, + ]); + } + + #[Test] + public function theHostIsProbedWhenTheLibcDecides(): void + { + $container = new LibcContainer(Libc::Musl); + + $selection = RuleRunner::run(new LibcRule($container), self::ASSETS); + + Assert::same($container->requests, 1); + Assert::same(RuleRunner::ranks($selection, 'libc')['tool-x86_64-unknown-linux-musl.tar.gz'], 0); + } } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/LibcContainer.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/LibcContainer.php new file mode 100644 index 00000000..f79f7da4 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/LibcContainer.php @@ -0,0 +1,34 @@ + */ + public int $requests = 0; + + public function __construct( + private readonly Libc $libc, + ) {} + + public function get(string $id): Libc + { + $id === Libc::class or throw new \LogicException("Unexpected request for `$id`."); + ++$this->requests; + + return $this->libc; + } + + public function has(string $id): bool + { + return $id === Libc::class; + } +} From f8b6823c3e48e493d12191110e797f447bed05d7 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 10:55:13 +0400 Subject: [PATCH 08/14] feat(downloader): fall back to x86-64 builds on ARM macOS and Windows Rosetta 2 and the Windows emulation run x86-64 binaries on ARM, so such a build is no longer discarded when a release has no ARM build for the host. Linux is left out: it needs qemu-user set up to do the same. Assisted-By: Claude Opus 5.5 --- .../references/registry-entry.md | 2 +- .../Internal/AssetSelection/AssetSelector.php | 2 +- .../AssetSelection/Rule/ArchitectureRule.php | 31 +++++++++-- .../AssetSelection/AssetSelectorTest.php | 12 +++++ .../Rule/ArchitectureRuleTest.php | 51 +++++++++++++++---- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 9726ac49..6035f7aa 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,7 +22,7 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. -Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are never picked on Linux. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are never picked on Linux. On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index 196f7902..c668e0d5 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -51,7 +51,7 @@ public function __construct( new NamePatternRule(), new FormatRule($archiveFactory), new OperatingSystemRule($operatingSystem), - new ArchitectureRule($architecture), + new ArchitectureRule($architecture, $operatingSystem), new LibcRule($container), new ExtrasRule(), new ArchiveRule($archiveFactory), diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php index 40f031a6..45dece2a 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php @@ -5,14 +5,15 @@ namespace Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule; use Internal\DLoad\Module\Common\Architecture; +use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Candidate; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Selection; /** - * Prefers assets built for the host architecture. + * Prefers assets built for the host architecture, then the ones the host emulates. * - * A strict selection removes the others; otherwise they are ranked last. + * A strict selection removes the assets the host cannot run; otherwise they are ranked last. * * @internal * @psalm-internal Internal\DLoad\Module\Downloader @@ -21,13 +22,33 @@ final class ArchitectureRule implements AssetRule { public function __construct( private readonly Architecture $architecture, + private readonly OperatingSystem $operatingSystem, ) {} public function select(Selection $selection, callable $next): Selection { - $fits = fn(Candidate $candidate): bool => $candidate->asset->getArchitecture() === $this->architecture; - $selection->strict and $selection = $selection->remove(static fn(Candidate $candidate): bool => !$fits($candidate)); + $selection->strict and $selection = $selection->remove( + fn(Candidate $candidate): bool => $this->rank($candidate->asset->getArchitecture()) === null, + ); - return $next($selection->prefer('arch', $fits)); + return $next($selection->rank( + 'arch', + fn(Candidate $candidate): int => $this->rank($candidate->asset->getArchitecture()) ?? 2, + )); + } + + /** + * @return int<0, 1>|null Null when the host cannot run the asset. + */ + private function rank(?Architecture $arch): ?int + { + return match (true) { + $arch === $this->architecture => 0, + // Rosetta 2 on macOS and the built-in emulation of Windows run x86-64 builds on ARM + $arch === Architecture::X86_64 + && $this->architecture === Architecture::ARM_64 + && \in_array($this->operatingSystem, [OperatingSystem::Darwin, OperatingSystem::Windows], true) => 1, + default => null, + }; } } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 932f1fd9..163166d9 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -90,6 +90,18 @@ public function androidPrefersAndroidBuildsAndFallsBackToLinuxOnes(): void Assert::same($names, ['tool-linux-amd64-android.zip', 'tool-linux-amd64.zip']); } + #[Test] + public function appleSiliconFallsBackToAnX86Build(): void + { + $names = self::select( + ['tool-linux-arm64.zip', 'tool-darwin-amd64.zip'], + os: OperatingSystem::Darwin, + arch: Architecture::ARM_64, + ); + + Assert::same($names, ['tool-darwin-amd64.zip']); + } + #[Test] public function aGradualSelectionPutsOtherPlatformsAfterTheHostOne(): void { diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php index b05c751f..5b4dc155 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php @@ -5,38 +5,71 @@ namespace Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Rule; use Internal\DLoad\Module\Common\Architecture; +use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\RuleRunner; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; use Testo\Test; #[Covers(ArchitectureRule::class)] final class ArchitectureRuleTest { private const ASSETS = [ - 'tool-linux-x86_64.zip', - 'tool-linux-aarch64.zip', - 'tool-linux.zip', + 'tool-x86_64.zip', + 'tool-aarch64.zip', + 'tool.zip', ]; + public static function provideEmulatingHosts(): iterable + { + yield 'macOS' => [OperatingSystem::Darwin]; + yield 'Windows' => [OperatingSystem::Windows]; + } + #[Test] public function aStrictSelectionKeepsOnlyTheHostArchitecture(): void { - $selection = RuleRunner::run(new ArchitectureRule(Architecture::ARM_64), self::ASSETS); + $selection = RuleRunner::run(new ArchitectureRule(Architecture::ARM_64, OperatingSystem::Linux), self::ASSETS); - Assert::same(RuleRunner::ranks($selection, 'arch'), ['tool-linux-aarch64.zip' => 0]); + Assert::same(RuleRunner::ranks($selection, 'arch'), ['tool-aarch64.zip' => 0]); } #[Test] public function aGradualSelectionRanksOtherArchitecturesLast(): void { - $selection = RuleRunner::run(new ArchitectureRule(Architecture::ARM_64), self::ASSETS, strict: false); + $selection = RuleRunner::run( + new ArchitectureRule(Architecture::ARM_64, OperatingSystem::Linux), + self::ASSETS, + strict: false, + ); Assert::same(RuleRunner::ranks($selection, 'arch'), [ - 'tool-linux-x86_64.zip' => 1, - 'tool-linux-aarch64.zip' => 0, - 'tool-linux.zip' => 1, + 'tool-x86_64.zip' => 2, + 'tool-aarch64.zip' => 0, + 'tool.zip' => 2, ]); } + + #[DataProvider('provideEmulatingHosts')] + #[Test] + public function armHostsThatEmulateX86KeepItsBuildsAsAFallback(OperatingSystem $os): void + { + $selection = RuleRunner::run(new ArchitectureRule(Architecture::ARM_64, $os), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'arch'), [ + 'tool-x86_64.zip' => 1, + 'tool-aarch64.zip' => 0, + ]); + } + + #[DataProvider('provideEmulatingHosts')] + #[Test] + public function x86HostsDoNotRunArmBuilds(OperatingSystem $os): void + { + $selection = RuleRunner::run(new ArchitectureRule(Architecture::X86_64, $os), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'arch'), ['tool-x86_64.zip' => 0]); + } } From dea0e65d366afd14cb28e3d00b5ac9febd1d2439 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 11:31:15 +0400 Subject: [PATCH 09/14] fix(downloader): never select checksums and signatures A companion file carries the platform of the build it describes, so it could outrank a build of another libc or variant, and downloading it succeeds, which ended the search with a file that is not the tool. Assisted-By: Claude Opus 5.5 --- .../Internal/AssetSelection/AssetSelector.php | 2 + .../AssetSelection/Rule/CompanionRule.php | 30 ++++++++++++++ .../AssetSelection/AssetSelectorTest.php | 10 +++++ .../AssetSelection/Rule/CompanionRuleTest.php | 41 +++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 src/Module/Downloader/Internal/AssetSelection/Rule/CompanionRule.php create mode 100644 tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/CompanionRuleTest.php diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index c668e0d5..26bcd209 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -11,6 +11,7 @@ use Internal\DLoad\Module\Config\Schema\Action\Type; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchiveRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\CompanionRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ExtrasRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\FormatRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; @@ -50,6 +51,7 @@ public function __construct( $pipeline = Pipeline::prepare( new NamePatternRule(), new FormatRule($archiveFactory), + new CompanionRule(), new OperatingSystemRule($operatingSystem), new ArchitectureRule($architecture, $operatingSystem), new LibcRule($container), diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/CompanionRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/CompanionRule.php new file mode 100644 index 00000000..bea1a911 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/CompanionRule.php @@ -0,0 +1,30 @@ +remove( + static fn(Candidate $candidate): bool => \preg_match(self::PATTERN, $candidate->asset->getName()) === 1, + )); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 163166d9..8a597b9e 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -12,6 +12,7 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchiveRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\CompanionRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ExtrasRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\FormatRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; @@ -27,6 +28,7 @@ #[Covers(AssetSelector::class)] #[Covers(NamePatternRule::class)] #[Covers(FormatRule::class)] +#[Covers(CompanionRule::class)] #[Covers(OperatingSystemRule::class)] #[Covers(ArchitectureRule::class)] #[Covers(LibcRule::class)] @@ -177,6 +179,14 @@ public function aPlainBinaryComesBeforeAnArchivedVariant(): void Assert::same($names, ['tool-linux-amd64', 'tool-linux-amd64-debug.tar.gz']); } + #[Test] + public function aChecksumNeverOutranksABuild(): void + { + $names = self::select(['tool-linux-amd64.tar.gz.sha256', 'tool-linux-amd64-musl.tar.gz']); + + Assert::same($names, ['tool-linux-amd64-musl.tar.gz']); + } + #[Test] public function archivesComeBeforeOtherFiles(): void { diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/CompanionRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/CompanionRuleTest.php new file mode 100644 index 00000000..958a4382 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/CompanionRuleTest.php @@ -0,0 +1,41 @@ + Date: Tue, 6 Oct 2026 11:38:08 +0400 Subject: [PATCH 10/14] fix(downloader): prefer musl builds on Android Android has its own libc, which runs no glibc build, while musl builds are usually static. A configured OS other than Linux now also yields glibc instead of probing the host for musl. Assisted-By: Claude Opus 5.5 --- src/Module/Common/Libc.php | 13 ++++++++----- tests/Unit/Module/Common/LibcTest.php | 15 ++++++++++++++- .../AssetSelection/AssetSelectorTest.php | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/Module/Common/Libc.php b/src/Module/Common/Libc.php index c9886b9e..195310b6 100644 --- a/src/Module/Common/Libc.php +++ b/src/Module/Common/Libc.php @@ -10,8 +10,9 @@ /** * C standard library a binary is linked against. * - * Only Linux distributions differ here: Alpine and a few others use musl. Every other host is - * treated as {@see self::Gnu}, which keeps musl builds a fallback there. + * Only Linux distributions differ here: Alpine and a few others use musl. Android counts as + * {@see self::Musl}: its own libc runs no glibc build, while musl builds are usually static. + * Every other host is treated as {@see self::Gnu}, which keeps musl builds a fallback there. * * ```php * // Recommended: Get from container (autowired with build config); detection runs once @@ -28,10 +29,12 @@ enum Libc: string implements Factoriable case Gnu = 'gnu'; case Musl = 'musl'; - public static function create(Build $config): self + public static function create(Build $config, OperatingSystem $os): self { - return match (\strtolower((string) $config->os)) { - 'alpine', 'unknown-musl' => self::Musl, + return match (true) { + \in_array(\strtolower((string) $config->os), ['alpine', 'unknown-musl'], true) => self::Musl, + $os === OperatingSystem::Android => self::Musl, + $os !== OperatingSystem::Linux => self::Gnu, default => self::fromGlobals(), }; } diff --git a/tests/Unit/Module/Common/LibcTest.php b/tests/Unit/Module/Common/LibcTest.php index 55b0c830..dbd9d463 100644 --- a/tests/Unit/Module/Common/LibcTest.php +++ b/tests/Unit/Module/Common/LibcTest.php @@ -6,6 +6,7 @@ use Internal\DLoad\Module\Common\Input\Build; use Internal\DLoad\Module\Common\Libc; +use Internal\DLoad\Module\Common\OperatingSystem; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; @@ -48,7 +49,19 @@ public function aMuslOsOptionSelectsMusl(): void $build = new Build(); $build->os = 'alpine'; - Assert::same(Libc::create($build), Libc::Musl); + Assert::same(Libc::create($build, OperatingSystem::Linux), Libc::Musl); + } + + #[Test] + public function androidPrefersMuslBuilds(): void + { + Assert::same(Libc::create(new Build(), OperatingSystem::Android), Libc::Musl); + } + + #[Test] + public function aHostOtherThanLinuxIsNotProbed(): void + { + Assert::same(Libc::create(new Build(), OperatingSystem::Darwin), Libc::Gnu); } #[DataProvider('provideHosts')] diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 8a597b9e..364fbf95 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -6,6 +6,7 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; +use Internal\DLoad\Module\Common\Input\Build; use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Config\Schema\Action\Type; @@ -179,6 +180,21 @@ public function aPlainBinaryComesBeforeAnArchivedVariant(): void Assert::same($names, ['tool-linux-amd64', 'tool-linux-amd64-debug.tar.gz']); } + #[Test] + public function androidPrefersAStaticMuslLinuxBuild(): void + { + $libc = Libc::create(new Build(), OperatingSystem::Android); + + $names = self::select( + ['tool-aarch64-unknown-linux-gnu.tar.gz', 'tool-aarch64-unknown-linux-musl.tar.gz'], + os: OperatingSystem::Android, + arch: Architecture::ARM_64, + libc: $libc, + ); + + Assert::same($names, ['tool-aarch64-unknown-linux-musl.tar.gz', 'tool-aarch64-unknown-linux-gnu.tar.gz']); + } + #[Test] public function aChecksumNeverOutranksABuild(): void { From d29e3e41a86ad218b1da8ad6e59581decd45c971 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 11:38:58 +0400 Subject: [PATCH 11/14] refactor(downloader): drop a bogus preg_match flag docs(skill): describe when Android builds are dropped `PREG_NO_ERROR` is an error code, not a flag; the `@` already silences an invalid pattern. Android builds are only dropped on Linux in a strict selection, and Android hosts now prefer musl builds. Assisted-By: Claude Opus 5.5 --- skills/dload-fetch-tool/references/registry-entry.md | 2 +- .../Downloader/Internal/AssetSelection/Rule/NamePatternRule.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 6035f7aa..2659a959 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,7 +22,7 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. -Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are never picked on Linux. On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower). On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php index b75e228b..8ef01bb3 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php @@ -24,7 +24,6 @@ public function select(Selection $selection, callable $next): Selection static fn(Candidate $candidate): bool => @\preg_match( $pattern, $candidate->asset->getName(), - flags: \PREG_NO_ERROR, ) !== 1, )); } From da1d2c5b5b2b983f9634e419de35635201097b59 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 11:40:54 +0400 Subject: [PATCH 12/14] feat(downloader): warn when an installed build needs x86-64 emulation Rosetta 2 is optional on macOS and Windows 10 on ARM has no x64 emulation, so a fallback build may not run. Assisted-By: Claude Opus 5.5 --- src/Module/Downloader/Downloader.php | 14 ++++++++++- .../AssetSelection/Rule/ArchitectureRule.php | 10 ++++++-- src/Service/Logger.php | 11 +++++++++ .../Module/Downloader/AssetSelectionTest.php | 24 ++++++++++++++++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index ce81830e..203cad75 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -19,6 +19,7 @@ use Internal\DLoad\Module\Downloader\Exception\ReleaseGone; use Internal\DLoad\Module\Downloader\Internal\Diagnostics\DownloadDiagnostics; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; use Internal\DLoad\Module\Downloader\Internal\DownloadContext; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; @@ -326,7 +327,18 @@ private function processRelease(DownloadContext $context): \Closure ); } - return $this->tryProcessAssets($selection->assets(), $context); + $asset = $this->tryProcessAssets($selection->assets(), $context); + foreach ($selection->candidates as $candidate) { + // The host runs this build only through emulation, which may be missing (Rosetta 2 is optional) + $candidate->asset === $asset && ($candidate->ranks[ArchitectureRule::KEY] ?? null) === ArchitectureRule::EMULATED and $this->logger->warning( + 'No `%s` build of `%s` found, `%s` needs an x86-64 emulator to run.', + $this->architecture->value, + $context->software->getId(), + $asset->getName(), + ); + } + + return $asset; }; } diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php index 45dece2a..42b44c92 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php @@ -20,6 +20,12 @@ */ final class ArchitectureRule implements AssetRule { + /** Name of the rank this rule gives. */ + public const KEY = 'arch'; + + /** Rank of an asset the host runs only through emulation. */ + public const EMULATED = 1; + public function __construct( private readonly Architecture $architecture, private readonly OperatingSystem $operatingSystem, @@ -32,7 +38,7 @@ public function select(Selection $selection, callable $next): Selection ); return $next($selection->rank( - 'arch', + self::KEY, fn(Candidate $candidate): int => $this->rank($candidate->asset->getArchitecture()) ?? 2, )); } @@ -47,7 +53,7 @@ private function rank(?Architecture $arch): ?int // Rosetta 2 on macOS and the built-in emulation of Windows run x86-64 builds on ARM $arch === Architecture::X86_64 && $this->architecture === Architecture::ARM_64 - && \in_array($this->operatingSystem, [OperatingSystem::Darwin, OperatingSystem::Windows], true) => 1, + && \in_array($this->operatingSystem, [OperatingSystem::Darwin, OperatingSystem::Windows], true) => self::EMULATED, default => null, }; } diff --git a/src/Service/Logger.php b/src/Service/Logger.php index c5946289..f5c39ffe 100644 --- a/src/Service/Logger.php +++ b/src/Service/Logger.php @@ -81,6 +81,17 @@ public function debug(string $message, string|int|float|bool ...$values): void $this->echo("\033[34m" . \sprintf($message, ...self::values($values)) . "\033[0m\n"); } + /** + * Outputs a warning message in yellow. + * + * @param string $message Message format string + * @param string|int|float|bool ...$values Format values + */ + public function warning(string $message, string|int|float|bool ...$values): void + { + $this->echo("\033[33m" . \sprintf($message, ...self::values($values)) . "\033[0m\n", false); + } + /** * Outputs an error message in red. * diff --git a/tests/Unit/Module/Downloader/AssetSelectionTest.php b/tests/Unit/Module/Downloader/AssetSelectionTest.php index 4e60918f..6414b395 100644 --- a/tests/Unit/Module/Downloader/AssetSelectionTest.php +++ b/tests/Unit/Module/Downloader/AssetSelectionTest.php @@ -27,6 +27,7 @@ use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RepositoryStub; use Internal\Path; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; +use Symfony\Component\Console\Output\BufferedOutput; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; @@ -210,6 +211,26 @@ public function tigerBeetleReleaseBuildIsSelectedOverTheDebugOne( Assert::same($result->file->getFilename(), $expected); } + #[Test] + public function anEmulatedBuildIsInstalledWithAWarning(): void + { + $output = new BufferedOutput(); + + $result = $this->download( + self::registryEntry('tigerbeetle'), + 'tigerbeetle/tigerbeetle', + '0.17.9', + ['tigerbeetle-x86_64-windows.zip', 'tigerbeetle-aarch64-linux.zip'], + OperatingSystem::Windows, + Architecture::ARM_64, + Libc::Gnu, + new Logger($output), + ); + + Assert::same($result->file->getFilename(), 'tigerbeetle-x86_64-windows.zip'); + Assert::string($output->fetch())->contains('needs an x86-64 emulator'); + } + #[DataProvider('provideMagoHosts')] #[Test] public function magoBuildForTheHostLibcIsSelected(Architecture $arch, Libc $libc, string $expected): void @@ -281,6 +302,7 @@ private function download( OperatingSystem $os, Architecture $arch, Libc $libc, + Logger $logger = new Logger(), ): DownloadResult { $repository = new RepositoryStub($repositoryName); $release = new ReleaseStub($repository, $tag, Version::fromVersionString(\preg_replace('/^[a-z]+-/', '', $tag)), tag: $tag); @@ -301,7 +323,7 @@ private function download( $downloader = new Downloader( config: $config, - logger: new Logger(), + logger: $logger, repositoryProvider: (new RepositoryProvider())->addRepositoryFactory( new SequenceRepositoryFactoryStub([$repository]), ), From 65e65d304aaaec6906c18611d0c74b3094e1d500 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 12:37:21 +0400 Subject: [PATCH 13/14] refactor(downloader): name the libc of every host feat(get): show the libc of Linux and Android hosts Android gets its own libc instead of posing as musl, and Windows, macOS and BSD get a system libc that ranks no build above another. Which builds a libc prefers now lives in the enum, so the rule only asks it. Assisted-By: Claude Opus 5.5 --- src/Command/Get.php | 10 +++- src/Module/Common/Libc.php | 50 ++++++++++++++++--- .../Internal/AssetSelection/Rule/LibcRule.php | 15 ++---- tests/Unit/Module/Common/LibcTest.php | 28 +++++++++-- .../AssetSelection/Rule/LibcRuleTest.php | 26 ++++++++++ 5 files changed, 107 insertions(+), 22 deletions(-) diff --git a/src/Command/Get.php b/src/Command/Get.php index 320bf1ae..81730046 100644 --- a/src/Command/Get.php +++ b/src/Command/Get.php @@ -120,7 +120,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('Architecture: ' . $container->get(Architecture::class)->name); $output->writeln( ' Op. system: ' . $container->get(OperatingSystem::class)->name - . ($container->get(Libc::class) === Libc::Musl ? ' (musl)' : ''), + . self::describeLibc($container->get(Libc::class)), ); $output->writeln(' Stability: ' . $container->get(Stability::class)->name); @@ -263,6 +263,14 @@ private static function applyFlags(InputInterface $input, Container $container): )); } + /** + * Names the libc next to the OS where builds differ by it. + */ + private static function describeLibc(Libc $libc): string + { + return $libc === Libc::System ? '' : " ({$libc->value})"; + } + /** * Prints the reason of every failed download and returns a failure exit code. * diff --git a/src/Module/Common/Libc.php b/src/Module/Common/Libc.php index 195310b6..99e81b2a 100644 --- a/src/Module/Common/Libc.php +++ b/src/Module/Common/Libc.php @@ -8,11 +8,9 @@ use Internal\DLoad\Module\Common\Input\Build; /** - * C standard library a binary is linked against. + * C standard library of a host or a build. * - * Only Linux distributions differ here: Alpine and a few others use musl. Android counts as - * {@see self::Musl}: its own libc runs no glibc build, while musl builds are usually static. - * Every other host is treated as {@see self::Gnu}, which keeps musl builds a fallback there. + * Builds name only {@see self::Gnu} or {@see self::Musl}; the other cases describe hosts. * * ```php * // Recommended: Get from container (autowired with build config); detection runs once @@ -26,15 +24,32 @@ */ enum Libc: string implements Factoriable { + /** + * glibc: most Linux distributions. + */ case Gnu = 'gnu'; + + /** + * musl: Alpine and a few other Linux distributions. + */ case Musl = 'musl'; + /** + * Android's own libc: it runs no glibc build, while musl builds are usually static. + */ + case Bionic = 'bionic'; + + /** + * The one libc the OS ships, like on Windows, macOS and BSD: builds do not differ by it. + */ + case System = 'system'; + public static function create(Build $config, OperatingSystem $os): self { return match (true) { \in_array(\strtolower((string) $config->os), ['alpine', 'unknown-musl'], true) => self::Musl, - $os === OperatingSystem::Android => self::Musl, - $os !== OperatingSystem::Linux => self::Gnu, + $os === OperatingSystem::Android => self::Bionic, + $os !== OperatingSystem::Linux => self::System, default => self::fromGlobals(), }; } @@ -51,7 +66,7 @@ public static function fromGlobals(): self public static function detect(string $osFamily, string $root): self { if ($osFamily !== 'Linux') { - return self::Gnu; + return self::System; } // The musl dynamic loader exists on musl systems only, and checking it runs nothing @@ -61,6 +76,11 @@ public static function detect(string $osFamily, string $root): self return \is_array($loaders) && $loaders !== [] ? self::Musl : self::Gnu; } + /** + * Reads the libc a build is linked against from its name. + * + * @return self::Gnu|self::Musl|null Null when the name tells nothing. + */ public static function tryFromBuildName(string $name): ?self { if (\preg_match('/(?:\b|_)(musl(?:eabi(?:hf)?)?|alpine|gnu(?:eabi(?:hf)?)?|glibc)(?:\b|_)/i', $name, $matches) !== 1) { @@ -70,4 +90,20 @@ public static function tryFromBuildName(string $name): ?self $token = \strtolower($matches[1]); return \str_starts_with($token, 'musl') || $token === 'alpine' ? self::Musl : self::Gnu; } + + /** + * Whether a host with this libc prefers the build. + * + * A build that names no libc counts as a glibc one. + * + * @param self|null $build Libc the build names. + */ + public function prefers(?self $build): bool + { + return match ($this) { + self::Gnu => $build !== self::Musl, + self::Musl, self::Bionic => $build === self::Musl, + self::System => true, + }; + } } diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php index 60c49edb..9e6ea6d9 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php @@ -11,11 +11,11 @@ use Psr\Container\ContainerInterface; /** - * Prefers assets linked against the host libc. + * Prefers the builds the host libc prefers, see {@see Libc::prefers()}. * * Ranks rather than removes: a static build runs whatever the libc, and some tools publish - * only a musl build for Linux. An asset that names no libc counts as a glibc one. The host libc - * comes from the container only when the assets differ in libc: resolving it probes the file system. + * only a musl build for Linux. The host libc comes from the container only when the assets + * differ in libc: resolving it probes the file system. * * @internal * @psalm-internal Internal\DLoad\Module\Downloader @@ -30,7 +30,7 @@ public function select(Selection $selection, callable $next): Selection { $libcs = []; foreach ($selection->candidates as $candidate) { - $libcs[self::libc($candidate)->value] = true; + $libcs[($candidate->name->libc ?? Libc::Gnu)->value] = true; } // The host is probed only when there is a choice between libcs @@ -40,11 +40,6 @@ public function select(Selection $selection, callable $next): Selection /** @var Libc $host */ $host = $this->container->get(Libc::class); - return $next($selection->prefer('libc', static fn(Candidate $candidate): bool => self::libc($candidate) === $host)); - } - - private static function libc(Candidate $candidate): Libc - { - return $candidate->name->libc ?? Libc::Gnu; + return $next($selection->prefer('libc', static fn(Candidate $candidate): bool => $host->prefers($candidate->name->libc))); } } diff --git a/tests/Unit/Module/Common/LibcTest.php b/tests/Unit/Module/Common/LibcTest.php index dbd9d463..9e9333a5 100644 --- a/tests/Unit/Module/Common/LibcTest.php +++ b/tests/Unit/Module/Common/LibcTest.php @@ -33,7 +33,20 @@ public static function provideHosts(): iterable { yield 'Linux with the musl loader' => ['Linux', true, Libc::Musl]; yield 'Linux without it' => ['Linux', false, Libc::Gnu]; - yield 'not Linux' => ['Darwin', true, Libc::Gnu]; + yield 'not Linux' => ['Darwin', true, Libc::System]; + } + + public static function providePreferences(): iterable + { + yield 'glibc host, glibc build' => [Libc::Gnu, Libc::Gnu, true]; + yield 'glibc host, plain build' => [Libc::Gnu, null, true]; + yield 'glibc host, musl build' => [Libc::Gnu, Libc::Musl, false]; + yield 'musl host, musl build' => [Libc::Musl, Libc::Musl, true]; + yield 'musl host, plain build' => [Libc::Musl, null, false]; + yield 'Android, musl build' => [Libc::Bionic, Libc::Musl, true]; + yield 'Android, glibc build' => [Libc::Bionic, Libc::Gnu, false]; + yield 'system libc, musl build' => [Libc::System, Libc::Musl, true]; + yield 'system libc, glibc build' => [Libc::System, Libc::Gnu, true]; } #[DataProvider('provideBuildNames')] @@ -43,6 +56,13 @@ public function tryFromBuildName(string $name, ?Libc $expected): void Assert::same(Libc::tryFromBuildName($name), $expected); } + #[DataProvider('providePreferences')] + #[Test] + public function prefers(Libc $host, ?Libc $build, bool $expected): void + { + Assert::same($host->prefers($build), $expected); + } + #[Test] public function aMuslOsOptionSelectsMusl(): void { @@ -53,15 +73,15 @@ public function aMuslOsOptionSelectsMusl(): void } #[Test] - public function androidPrefersMuslBuilds(): void + public function androidHasItsOwnLibc(): void { - Assert::same(Libc::create(new Build(), OperatingSystem::Android), Libc::Musl); + Assert::same(Libc::create(new Build(), OperatingSystem::Android), Libc::Bionic); } #[Test] public function aHostOtherThanLinuxIsNotProbed(): void { - Assert::same(Libc::create(new Build(), OperatingSystem::Darwin), Libc::Gnu); + Assert::same(Libc::create(new Build(), OperatingSystem::Darwin), Libc::System); } #[DataProvider('provideHosts')] diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php index c600b5f2..4d49557e 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/LibcRuleTest.php @@ -45,6 +45,32 @@ public function aMuslHostRanksMuslBuildsFirst(): void ]); } + #[Test] + public function androidRanksMuslBuildsFirst(): void + { + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::Bionic)), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-x86_64-unknown-linux-gnu.tar.gz' => 1, + 'tool-x86_64-unknown-linux-musl.tar.gz' => 0, + 'tool-linux-amd64.tar.gz' => 1, + ]); + } + + #[Test] + public function aSystemLibcRanksNothing(): void + { + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::System)), [ + 'tool-x86_64-pc-windows-gnu.tar.gz', + 'tool-x86_64-pc-windows-msvc.zip', + ]); + + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-x86_64-pc-windows-gnu.tar.gz' => 0, + 'tool-x86_64-pc-windows-msvc.zip' => 0, + ]); + } + #[Test] public function nothingIsRemoved(): void { From 534b7bf56a69efb0ce1e17f199a580fac9b05a4a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 6 Oct 2026 12:42:02 +0400 Subject: [PATCH 14/14] test(common): cover the remaining host detection branches Assisted-By: Claude Opus 5.5 --- tests/Unit/Module/Common/LibcTest.php | 6 ++++++ tests/Unit/Module/Common/OperatingSystemTest.php | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/tests/Unit/Module/Common/LibcTest.php b/tests/Unit/Module/Common/LibcTest.php index 9e9333a5..417a298c 100644 --- a/tests/Unit/Module/Common/LibcTest.php +++ b/tests/Unit/Module/Common/LibcTest.php @@ -100,4 +100,10 @@ public function muslIsDetectedByItsLoaderOnLinux(string $osFamily, bool $loader, \rmdir($root); } } + + #[Test] + public function theCurrentHostIsProbedAtTheFileSystemRoot(): void + { + Assert::same(Libc::fromGlobals(), Libc::detect(\PHP_OS_FAMILY, '/')); + } } diff --git a/tests/Unit/Module/Common/OperatingSystemTest.php b/tests/Unit/Module/Common/OperatingSystemTest.php index 73772bd8..3ff34d92 100644 --- a/tests/Unit/Module/Common/OperatingSystemTest.php +++ b/tests/Unit/Module/Common/OperatingSystemTest.php @@ -7,6 +7,7 @@ use Internal\DLoad\Module\Common\OperatingSystem; use Testo\Assert; use Testo\Data\DataProvider; +use Testo\Expect; use Testo\Test; class OperatingSystemTest @@ -46,4 +47,18 @@ public function androidIsDetectedByItsRuntimeOnLinux(string $osFamily, bool $and { Assert::same(OperatingSystem::fromHost($osFamily, $androidRuntime), $expected); } + + #[Test] + public function androidCanBeRequestedByName(): void + { + Assert::same(OperatingSystem::tryFromString('android'), OperatingSystem::Android); + } + + #[Test] + public function anUnknownHostFamilyIsRejected(): void + { + Expect::exception(\OutOfRangeException::class); + + OperatingSystem::fromHost('Solaris', false); + } }