From f5ea2431606de4c1aba1f5fb6eb4320d18241ca5 Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Mon, 10 Feb 2025 19:07:24 +0000 Subject: [PATCH 1/5] Forgejo: add custom driver for codeberg/forgejo repositories --- doc/06-config.md | 16 + .../authentication-for-private-packages.md | 38 +++ src/Composer/Command/ConfigCommand.php | 10 +- src/Composer/Config.php | 4 +- src/Composer/Config/JsonConfigSource.php | 6 +- src/Composer/IO/BaseIO.php | 10 + src/Composer/Repository/Vcs/ForgejoDriver.php | 321 ++++++++++++++++++ src/Composer/Repository/Vcs/VcsDriver.php | 1 + src/Composer/Repository/VcsRepository.php | 1 + src/Composer/Util/ForgejoRepositoryData.php | 59 ++++ src/Composer/Util/ForgejoUrl.php | 67 ++++ .../Test/Repository/Vcs/ForgejoDriverTest.php | 191 +++++++++++ tests/Composer/Test/Util/ForgejoUrlTest.php | 46 +++ 13 files changed, 764 insertions(+), 6 deletions(-) create mode 100644 src/Composer/Repository/Vcs/ForgejoDriver.php create mode 100644 src/Composer/Util/ForgejoRepositoryData.php create mode 100644 src/Composer/Util/ForgejoUrl.php create mode 100644 tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php create mode 100644 tests/Composer/Test/Util/ForgejoUrlTest.php diff --git a/doc/06-config.md b/doc/06-config.md index 85a138b6a445..c6bbda8cbd0e 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -246,6 +246,22 @@ private repositories which will later be cloned in GitLab CI jobs with a using HTTP basic auth. By default, Composer will generate a git-over-SSH URL for private repositories and HTTP(S) only for public. +## forgejo-domains + +Defaults to `["codeberg.org"]`. A list of domains of Forgejo servers. +This is used if you use the `forgejo` repository type. + +## forgejo-token + +A list of domain names and username/access-tokens to authenticate against them. For +example using `{"codeberg.org": {"username": "forgejo-user", "token": "access-token"}}` as the +value of this option will let Composer authenticate against codeberg.org. +Please note: If the package is not hosted at +codeberg.org the domain names must be also specified with the +[`forgejo-domains`](06-config.md#forgejo-domains) option. +Further info can also be found [here](articles/authentication-for-private-packages.md#forgejo-token) + + ## disable-tls Defaults to `false`. If set to true all HTTPS URLs will be tried with HTTP diff --git a/doc/articles/authentication-for-private-packages.md b/doc/articles/authentication-for-private-packages.md index fb86c5c23391..36560a762350 100644 --- a/doc/articles/authentication-for-private-packages.md +++ b/doc/articles/authentication-for-private-packages.md @@ -360,3 +360,41 @@ php composer.phar config [--global] --editor --auth } } ``` + +## forgejo-token + +> **Note:** For the forge authentication to work on private Forgejo instances, the +> [`forgejo-domains`](../06-config.md#forgejo-domains) section should also contain the URL. + +To create a new access token, go to your [applications section on Forgejo](https://codeberg.org/user/settings/applications) +(or the equivalent URL on your private instance) and create a new access token. See also [the Forgejo access token documentation](https://docs.codeberg.org/advanced/access-token/) for more information. + +When creating a Forgejo access token, make sure it has the `read:repository` scope. + +### Command line gitlab-token + +```shell +php composer.phar config [--global] forgejo-token.gitlab.example.org username access-token +``` + +In the above command, the config key `forgejo-token.forgejo.example.org` consists of two parts: + +- `forgejo-token` is the authentication method. +- `forgejo.example.org` is the host name of your Forgejo instance, you should replace it with the host name of your Forgejo instance or use `codeberg.org` if you don't have a self-hosted Forgejo instance. + +### Manual forgejo-token + +```shell +php composer.phar config [--global] --editor --auth +``` + +```json +{ + "forgejo-token": { + "forgejo.example.org": { + "username": "forgejo-user", + "token": "access-token" + } + } +} +``` diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index de3bd367ef77..c7bd4923a949 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -210,7 +210,7 @@ protected function initialize(InputInterface $input, OutputInterface $output): v } if ($input->getOption('global') && !$this->authConfigFile->exists()) { touch($this->authConfigFile->getPath()); - $this->authConfigFile->write(['bitbucket-oauth' => new \ArrayObject, 'github-oauth' => new \ArrayObject, 'gitlab-oauth' => new \ArrayObject, 'gitlab-token' => new \ArrayObject, 'http-basic' => new \ArrayObject, 'bearer' => new \ArrayObject]); + $this->authConfigFile->write(['bitbucket-oauth' => new \ArrayObject, 'github-oauth' => new \ArrayObject, 'gitlab-oauth' => new \ArrayObject, 'gitlab-token' => new \ArrayObject, 'http-basic' => new \ArrayObject, 'bearer' => new \ArrayObject, 'forgejo-token' => new \ArrayObject()]); Silencer::call('chmod', $this->authConfigFile->getPath(), 0600); } @@ -838,7 +838,7 @@ static function ($vals) { } // handle auth - if (Preg::isMatch('/^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|http-basic|bearer)\.(.+)/', $settingKey, $matches)) { + if (Preg::isMatch('/^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|http-basic|bearer|forgejo-token)\.(.+)/', $settingKey, $matches)) { if ($input->getOption('unset')) { $this->authConfigSource->removeConfigSetting($matches[1].'.'.$matches[2]); $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]); @@ -867,6 +867,12 @@ static function ($vals) { } $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]); $this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], ['username' => $values[0], 'password' => $values[1]]); + } elseif ($matches[1] === 'forgejo-token') { + if (2 !== count($values)) { + throw new \RuntimeException('Expected two arguments (username, access token), got '.count($values)); + } + $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]); + $this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], ['username' => $values[0], 'token' => $values[1]]); } return 0; diff --git a/src/Composer/Config.php b/src/Composer/Config.php index f39579e06b7a..03968882475a 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -86,6 +86,8 @@ class Config 'bearer' => [], 'bump-after-update' => false, 'allow-missing-requirements' => false, + 'forgejo-domains' => ['codeberg.org'], + 'forgejo-token' => [], ]; /** @var array */ @@ -191,7 +193,7 @@ public function merge(array $config, string $source = self::SOURCE_UNKNOWN): voi // override defaults with given config if (!empty($config['config']) && is_array($config['config'])) { foreach ($config['config'] as $key => $val) { - if (in_array($key, ['bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer'], true) && isset($this->config[$key])) { + if (in_array($key, ['bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer', 'forgejo-token'], true) && isset($this->config[$key])) { $this->config[$key] = array_merge($this->config[$key], $val); $this->setSourceOfConfigValue($val, $key, $source); } elseif (in_array($key, ['allow-plugins'], true) && isset($this->config[$key]) && is_array($this->config[$key]) && is_array($val)) { diff --git a/src/Composer/Config/JsonConfigSource.php b/src/Composer/Config/JsonConfigSource.php index 596d14f52848..a596f19bd114 100644 --- a/src/Composer/Config/JsonConfigSource.php +++ b/src/Composer/Config/JsonConfigSource.php @@ -100,7 +100,7 @@ public function addConfigSetting(string $name, $value): void { $authConfig = $this->authConfig; $this->manipulateJson('addConfigSetting', static function (&$config, $key, $val) use ($authConfig): void { - if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|platform)\.}', $key)) { + if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|forgejo-token|platform)\.}', $key)) { [$key, $host] = explode('.', $key, 2); if ($authConfig) { $config[$key][$host] = $val; @@ -120,7 +120,7 @@ public function removeConfigSetting(string $name): void { $authConfig = $this->authConfig; $this->manipulateJson('removeConfigSetting', static function (&$config, $key) use ($authConfig): void { - if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|platform)\.}', $key)) { + if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|forgejo-token|platform)\.}', $key)) { [$key, $host] = explode('.', $key, 2); if ($authConfig) { unset($config[$key][$host]); @@ -262,7 +262,7 @@ private function manipulateJson(string $method, callable $fallback, ...$args): v $config['autoload-dev'][$prop] = new \stdClass; } } - foreach (['platform', 'http-basic', 'bearer', 'gitlab-token', 'gitlab-oauth', 'github-oauth', 'preferred-install'] as $prop) { + foreach (['platform', 'http-basic', 'bearer', 'gitlab-token', 'gitlab-oauth', 'github-oauth', 'forgejo-token', 'preferred-install'] as $prop) { if (isset($config['config'][$prop]) && $config['config'][$prop] === []) { $config['config'][$prop] = new \stdClass; } diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php index 55ac166044a6..c603e6ca4225 100644 --- a/src/Composer/IO/BaseIO.php +++ b/src/Composer/IO/BaseIO.php @@ -119,6 +119,7 @@ public function loadConfiguration(Config $config) $githubOauth = $config->get('github-oauth'); $gitlabOauth = $config->get('gitlab-oauth'); $gitlabToken = $config->get('gitlab-token'); + $forgejoToken = $config->get('forgejo-token'); $httpBasic = $config->get('http-basic'); $bearerToken = $config->get('bearer'); @@ -163,6 +164,15 @@ public function loadConfiguration(Config $config) $this->checkAndSetAuthentication($domain, $username, $password); } + foreach ($forgejoToken as $domain => $cred) { + if (!in_array($domain, $config->get('forgejo-domains'), true)) { + $this->debug($domain.' is not in the configured forgejo-domains, adding it implicitly as authentication is configured for this domain'); + $config->merge(['config' => ['forgejo-domains' => array_merge($config->get('forgejo-domains'), [$domain])]], 'implicit-due-to-auth'); + } + + $this->checkAndSetAuthentication($domain, $cred['username'], $cred['token']); + } + // reload http basic credentials from config if available foreach ($httpBasic as $domain => $cred) { $this->checkAndSetAuthentication($domain, $cred['username'], $cred['password']); diff --git a/src/Composer/Repository/Vcs/ForgejoDriver.php b/src/Composer/Repository/Vcs/ForgejoDriver.php new file mode 100644 index 000000000000..555727f72b35 --- /dev/null +++ b/src/Composer/Repository/Vcs/ForgejoDriver.php @@ -0,0 +1,321 @@ + Map of tag name to identifier */ + private $tags; + /** @var array Map of branch name to identifier */ + private $branches; + + public function initialize(): void + { + $this->forgejoUrl = ForgejoUrl::create($this->url); + $this->originUrl = $this->forgejoUrl->originUrl; + + $this->cache = new Cache($this->io, $this->config->get('cache-repo-dir').'/'.$this->originUrl.'/'.$this->forgejoUrl->owner.'/'.$this->forgejoUrl->repository); + $this->cache->setReadOnly($this->config->get('cache-read-only')); + + $this->fetchRepositoryData(); + } + + public function getFileContent(string $file, string $identifier): ?string + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getFileContent($file, $identifier); + } + + $resource = $this->forgejoUrl->apiUrl.'/contents/' . $file . '?ref='.urlencode($identifier); + $resource = $this->getContents($resource)->decodeJson(); + + // The Forgejo contents API only returns files up to 1MB as base64 encoded files + // larger files either need be fetched with a raw accept header or by using the git blob endpoint + if ((!isset($resource['content']) || $resource['content'] === '') && $resource['encoding'] === 'none' && isset($resource['git_url'])) { + $resource = $this->getContents($resource['git_url'])->decodeJson(); + } + + if (!isset($resource['content']) || $resource['encoding'] !== 'base64' || false === ($content = base64_decode($resource['content'], true))) { + throw new \RuntimeException('Could not retrieve ' . $file . ' for '.$identifier); + } + + return $content; + } + + public function getChangeDate(string $identifier): ?\DateTimeImmutable + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getChangeDate($identifier); + } + + $resource = $this->forgejoUrl->apiUrl.'/git/commits/'.urlencode($identifier).'?verification=false&files=false'; + $commit = $this->getContents($resource)->decodeJson(); + + return new \DateTimeImmutable($commit['commit']['committer']['date']); + } + + public function getRootIdentifier(): string + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getRootIdentifier(); + } + + return $this->repositoryData->defaultBranch; + } + + public function getBranches(): array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getBranches(); + } + + if (null === $this->branches) { + $branches = []; + $resource = $this->forgejoUrl->apiUrl.'/branches?per_page=100'; + + do { + $response = $this->getContents($resource); + $branchData = $response->decodeJson(); + foreach ($branchData as $branch) { + $branches[$branch['name']] = $branch['commit']['id']; + } + + $resource = $this->getNextPage($response); + } while ($resource); + + $this->branches = $branches; + } + + return $this->branches; + } + + public function getTags(): array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getTags(); + } + if (null === $this->tags) { + $tags = []; + $resource = $this->forgejoUrl->apiUrl.'/tags?per_page=100'; + + do { + $response = $this->getContents($resource); + $tagsData = $response->decodeJson(); + foreach ($tagsData as $tag) { + $tags[$tag['tag_name']] = $tag['commit']['sha']; + } + + $resource = $this->getNextPage($response); + } while ($resource); + + $this->tags = $tags; + } + + return $this->tags; + } + + public function getDist(string $identifier): ?array + { + $url = $this->forgejoUrl->apiUrl.'/archive/'.$identifier.'.zip'; + + return ['type' => 'zip', 'url' => $url, 'reference' => $identifier, 'shasum' => '']; + } + + public function getComposerInformation(string $identifier): ?array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getComposerInformation($identifier); + } + + if (!isset($this->infoCache[$identifier])) { + if ($this->shouldCache($identifier) && false !== ($res = $this->cache->read($identifier))) { + $composer = JsonFile::parseJson($res); + } else { + $composer = $this->getBaseComposerInformation($identifier); + + if ($this->shouldCache($identifier)) { + $this->cache->write($identifier, (string) json_encode($composer)); + } + } + + if ($composer !== null) { + // specials for forgejo + if (isset($composer['support']) && !is_array($composer['support'])) { + $composer['support'] = []; + } + if (!isset($composer['support']['source'])) { + if (false !== ($label = array_search($identifier, $this->getTags(), true))) { + $composer['support']['source'] = $this->repositoryData->htmlUrl.'/tag/' . $label; + } elseif (false !== ($label = array_search($identifier, $this->getBranches(), true))) { + $composer['support']['source'] = $this->repositoryData->htmlUrl.'/branch/'.$label; + } else { + $composer['support']['source'] = $this->repositoryData->htmlUrl.'/commit/'.$identifier; + } + } + if (!isset($composer['support']['issues']) && $this->repositoryData->hasIssues) { + $composer['support']['issues'] = $this->repositoryData->htmlUrl.'/issues'; + } + if (!isset($composer['abandoned']) && $this->repositoryData->isArchived) { + $composer['abandoned'] = true; + } + } + + $this->infoCache[$identifier] = $composer; + } + + return $this->infoCache[$identifier]; + } + + public function getSource(string $identifier): array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getSource($identifier); + } + if ($this->repositoryData->isPrivate) { + // Private Forgejo repositories should be accessed using the + // SSH version of the URL. + $url = $this->repositoryData->sshUrl; + } else { + $url = $this->getUrl(); + } + + return ['type' => 'git', 'url' => $url, 'reference' => $identifier]; + } + + public function getUrl(): string + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getUrl(); + } + + return $this->repositoryData->isPrivate ? $this->repositoryData->sshUrl : $this->repositoryData->httpCloneUrl; + } + + public static function supports(IOInterface $io, Config $config, string $url, bool $deep = false): bool + { + $forgejoUrl = ForgejoUrl::tryFrom($url); + if ($forgejoUrl === null) { + return false; + } + + if (!in_array(strtolower($forgejoUrl->originUrl), $config->get('forgejo-domains'), true)) { + return false; + } + + if (!extension_loaded('openssl')) { + $io->writeError('Skipping Forgejo driver for '.$url.' because the OpenSSL PHP extension is missing.', true, IOInterface::VERBOSE); + + return false; + } + + return true; + } + + protected function setupGitDriver(string $url): void + { + $this->gitDriver = new GitDriver( + ['url' => $url], + $this->io, + $this->config, + $this->httpDownloader, + $this->process + ); + $this->gitDriver->initialize(); + } + + private function fetchRepositoryData(): void + { + if ($this->repositoryData !== null) { + return; + } + + $data = $this->getContents($this->forgejoUrl->apiUrl, true)->decodeJson(); + + if (null === $data && null !== $this->gitDriver) { + return; + } + + $this->repositoryData = ForgejoRepositoryData::fromRemoteData($data); + } + + protected function getNextPage(Response $response): ?string + { + $header = $response->getHeader('link'); + if ($header === null) { + return null; + } + + $links = explode(',', $header); + foreach ($links as $link) { + if (Preg::isMatch('{<(.+?)>; *rel="next"}', $link, $match)) { + return $match[1]; + } + } + + return null; + } + + protected function getContents(string $url, bool $fetchingRepoData = false): Response + { + try { + return parent::getContents($url); + } catch (TransportException $e) { + switch ($e->getCode()) { + case 404: + // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404 + if (!$fetchingRepoData) { + throw $e; + } + + if (!$this->io->isInteractive()) { + $this->attemptCloneFallback(); + + return new Response(['url' => 'dummy'], 200, [], 'null'); + } + default: + throw $e; + } + } + } + + /** + * @phpstan-impure + * + * @return true + * @throws \RuntimeException + */ + protected function attemptCloneFallback(): bool + { + try { + // If this repository may be private (hard to say for sure, + // Forgejo returns 404 for private repositories) and we + // cannot ask for authentication credentials (because we + // are not interactive) then we fallback to GitDriver. + $this->setupGitDriver($this->forgejoUrl->generateSshUr()); + + return true; + } catch (\RuntimeException $e) { + $this->gitDriver = null; + + $this->io->writeError('Failed to clone the '.$this->forgejoUrl->generateSshUr().' repository, try running in interactive mode so that you can enter your Forgejo credentials'); + throw $e; + } + } +} diff --git a/src/Composer/Repository/Vcs/VcsDriver.php b/src/Composer/Repository/Vcs/VcsDriver.php index b780304f3368..69b98faa7077 100644 --- a/src/Composer/Repository/Vcs/VcsDriver.php +++ b/src/Composer/Repository/Vcs/VcsDriver.php @@ -75,6 +75,7 @@ final public function __construct(array $repoConfig, IOInterface $io, Config $co /** * Returns whether or not the given $identifier should be cached or not. + * @phpstan-assert-if-true !null $this->cache */ protected function shouldCache(string $identifier): bool { diff --git a/src/Composer/Repository/VcsRepository.php b/src/Composer/Repository/VcsRepository.php index 5aaea602dafc..0fc628e7bfe9 100644 --- a/src/Composer/Repository/VcsRepository.php +++ b/src/Composer/Repository/VcsRepository.php @@ -83,6 +83,7 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, 'gitlab' => 'Composer\Repository\Vcs\GitLabDriver', 'bitbucket' => 'Composer\Repository\Vcs\GitBitbucketDriver', 'git-bitbucket' => 'Composer\Repository\Vcs\GitBitbucketDriver', + 'forgejo' => 'Composer\Repository\Vcs\ForgejoDriver', 'git' => 'Composer\Repository\Vcs\GitDriver', 'hg' => 'Composer\Repository\Vcs\HgDriver', 'perforce' => 'Composer\Repository\Vcs\PerforceDriver', diff --git a/src/Composer/Util/ForgejoRepositoryData.php b/src/Composer/Util/ForgejoRepositoryData.php new file mode 100644 index 000000000000..2a45e238d573 --- /dev/null +++ b/src/Composer/Util/ForgejoRepositoryData.php @@ -0,0 +1,59 @@ +htmlUrl = $htmlUrl; + $this->httpCloneUrl = $httpCloneUrl; + $this->sshUrl = $sshUrl; + $this->isPrivate = $isPrivate; + $this->defaultBranch = $defaultBranch; + $this->hasIssues = $hasIssues; + $this->isArchived = $isArchived; + } + + /** + * @param array $data + */ + public static function fromRemoteData(array $data): self + { + return new self( + $data['html_url'], + $data['clone_url'], + $data['ssh_url'], + $data['private'], + $data['default_branch'], + $data['has_issues'], + $data['archived'] + ); + } +} diff --git a/src/Composer/Util/ForgejoUrl.php b/src/Composer/Util/ForgejoUrl.php new file mode 100644 index 000000000000..263adada56f2 --- /dev/null +++ b/src/Composer/Util/ForgejoUrl.php @@ -0,0 +1,67 @@ +owner = $owner; + $this->repository = $repository; + $this->originUrl = $originUrl; + $this->apiUrl = $apiUrl; + } + + public static function create(string $repoUrl): self + { + $url = self::tryFrom($repoUrl); + if ($url !== null) { + return $url; + } + + throw new \InvalidArgumentException('This is not a valid Forgejo URL: ' . $repoUrl); + } + + public static function tryFrom(?string $repoUrl): ?self + { + if ($repoUrl === null || ! Preg::isMatch(self::URL_REGEX, $repoUrl, $match)) { + return null; + } + + $originUrl = strtolower($match[1] ?? (string) $match[2]); + $apiBase = $originUrl . '/api/v1'; + + return new self( + $match[3], + $match[4], + $originUrl, + sprintf('https://%s/repos/%s/%s', $apiBase, $match[3], $match[4]) + ); + } + + public function generateSshUr(): string + { + return 'git@' . $this->originUrl . ':'.$this->owner.'/'.$this->repository.'.git'; + } +} diff --git a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php new file mode 100644 index 000000000000..a107c11a3fe2 --- /dev/null +++ b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php @@ -0,0 +1,191 @@ +home = self::getUniqueTmpDirectory(); + $this->config = new Config(); + $this->config->merge([ + 'config' => [ + 'home' => $this->home, + 'forgejo-domains' => ['codeberg.org'], + ], + ]); + + $this->io = $this->getMockBuilder('Composer\IO\IOInterface')->getMock(); + $this->httpDownloader = $this->getHttpDownloaderMock($this->io, $this->config); + } + + protected function tearDown(): void + { + parent::tearDown(); + $fs = new Filesystem; + $fs->removeDirectory($this->home); + } + + public function testPublicRepository(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + self::assertEquals('main', $driver->getRootIdentifier()); + + $sha = 'SOMESHA'; + $dist = $driver->getDist($sha); + self::assertIsArray($dist); + self::assertEquals('zip', $dist['type']); + self::assertEquals('https://codeberg.org/api/v1/repos/acme/repo/archive/SOMESHA.zip', $dist['url']); + self::assertEquals($sha, $dist['reference']); + + $source = $driver->getSource($sha); + self::assertEquals('git', $source['type']); + self::assertEquals('https://codeberg.org/acme/repo.git', $source['url']); + self::assertEquals($sha, $source['reference']); + } + + public function testGetBranches(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo/branches?per_page=100', 'body' => (string) json_encode([ + ['name' => 'main', 'commit' => ['id' => 'SOMESHA']], + ])] + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + self::assertEquals(['main' => 'SOMESHA'], $driver->getBranches()); + } + + public function testGetTags(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo/tags?per_page=100', 'body' => (string) json_encode([ + ['tag_name' => '1.0', 'commit' => ['sha' => 'SOMESHA']] + ])] + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + self::assertEquals(['1.0' => 'SOMESHA'], $driver->getTags()); + } + + public function testGetEmptyFileContent(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo/contents/composer.json?ref=main', 'body' => '{"encoding":"base64","content":""}'] + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + + self::assertSame('', $driver->getFileContent('composer.json', 'main')); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(bool $expected, string $repoUrl): void + { + self::assertSame($expected, ForgejoDriver::supports($this->io, $this->config, $repoUrl)); + } + + /** + * @return list + */ + public static function supportsProvider(): array + { + return [ + [false, 'https://example.org/acme/repo'], + [true, 'https://codeberg.org/acme/repository'], + ]; + } + + private function initializeDriver(string $repoUrl): ForgejoDriver + { + $driver = new ForgejoDriver(['url' => $repoUrl], $this->io, $this->config, $this->httpDownloader, $this->getProcessExecutorMock()); + $driver->initialize(); + + return $driver; + } + + private function expectInteractiveIO(bool $isInteractive = true): void + { + $this->io->expects($this->any()) + ->method('isInteractive') + ->will($this->returnValue($isInteractive)); + } +} diff --git a/tests/Composer/Test/Util/ForgejoUrlTest.php b/tests/Composer/Test/Util/ForgejoUrlTest.php new file mode 100644 index 000000000000..2fc4889a0813 --- /dev/null +++ b/tests/Composer/Test/Util/ForgejoUrlTest.php @@ -0,0 +1,46 @@ +assertNotNull($forgejoUrl); + $this->assertSame('codeberg.org', $forgejoUrl->originUrl); + $this->assertSame('acme', $forgejoUrl->owner); + $this->assertSame('repo', $forgejoUrl->repository); + $this->assertSame('https://codeberg.org/api/v1/repos/acme/repo', $forgejoUrl->apiUrl); + } + + public static function createProvider(): array + { + return [ + ['git@codeberg.org:acme/repo.git'], + ['https://codeberg.org/acme/repo'], + ['https://codeberg.org/acme/repo.git'], + ]; + } + + public function testCreateInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + + ForgejoUrl::create('https://example.org'); + } + + public function testGenerateSshUrl(): void + { + $forgejoUrl = ForgejoUrl::create('git@codeberg.org:acme/repo.git'); + + $this->assertSame('git@codeberg.org:acme/repo.git', $forgejoUrl->generateSshUr()); + } +} From cad36a0fbd29ea023bc67a7b2c4a249bade266eb Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Mon, 10 Feb 2025 19:07:36 +0000 Subject: [PATCH 2/5] Forgejo: implement and document authentication for forgejo repositories --- .../authentication-for-private-packages.md | 4 +- src/Composer/Repository/Vcs/ForgejoDriver.php | 26 +++- src/Composer/Util/Forgejo.php | 108 ++++++++++++++ src/Composer/Util/ForgejoRepositoryData.php | 10 ++ src/Composer/Util/ForgejoUrl.php | 10 ++ tests/Composer/Test/ConfigTest.php | 1 + .../Test/Repository/Vcs/ForgejoDriverTest.php | 10 ++ tests/Composer/Test/Util/ForgejoTest.php | 133 ++++++++++++++++++ tests/Composer/Test/Util/ForgejoUrlTest.php | 10 ++ 9 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 src/Composer/Util/Forgejo.php create mode 100644 tests/Composer/Test/Util/ForgejoTest.php diff --git a/doc/articles/authentication-for-private-packages.md b/doc/articles/authentication-for-private-packages.md index 36560a762350..5f5abc20c1b3 100644 --- a/doc/articles/authentication-for-private-packages.md +++ b/doc/articles/authentication-for-private-packages.md @@ -371,10 +371,10 @@ To create a new access token, go to your [applications section on Forgejo](https When creating a Forgejo access token, make sure it has the `read:repository` scope. -### Command line gitlab-token +### Command line forgejo-token ```shell -php composer.phar config [--global] forgejo-token.gitlab.example.org username access-token +php composer.phar config [--global] forgejo-token.forgejo.example.org username access-token ``` In the above command, the config key `forgejo-token.forgejo.example.org` consists of two parts: diff --git a/src/Composer/Repository/Vcs/ForgejoDriver.php b/src/Composer/Repository/Vcs/ForgejoDriver.php index 555727f72b35..82c89ac8148b 100644 --- a/src/Composer/Repository/Vcs/ForgejoDriver.php +++ b/src/Composer/Repository/Vcs/ForgejoDriver.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Repository\Vcs; use Composer\Cache; @@ -8,6 +18,7 @@ use Composer\IO\IOInterface; use Composer\Json\JsonFile; use Composer\Pcre\Preg; +use Composer\Util\Forgejo; use Composer\Util\ForgejoRepositoryData; use Composer\Util\ForgejoUrl; use Composer\Util\Http\Response; @@ -274,12 +285,16 @@ protected function getNextPage(Response $response): ?string protected function getContents(string $url, bool $fetchingRepoData = false): Response { + $forgejo = new Forgejo($this->io, $this->config, $this->httpDownloader); + try { return parent::getContents($url); } catch (TransportException $e) { switch ($e->getCode()) { + case 401: + case 403: case 404: - // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404 + case 429: if (!$fetchingRepoData) { throw $e; } @@ -289,6 +304,15 @@ protected function getContents(string $url, bool $fetchingRepoData = false): Res return new Response(['url' => 'dummy'], 200, [], 'null'); } + + if ( + !$this->io->hasAuthentication($this->originUrl) && + $forgejo->authorizeOAuthInteractively($this->forgejoUrl->originUrl, $e->getCode() === 429 ? 'API limit exhausted. Enter your Forgejo credentials to get a larger API limit ('.$this->url.')' : null) + ) { + return parent::getContents($url); + } + + throw $e; default: throw $e; } diff --git a/src/Composer/Util/Forgejo.php b/src/Composer/Util/Forgejo.php new file mode 100644 index 000000000000..cb526e0472fb --- /dev/null +++ b/src/Composer/Util/Forgejo.php @@ -0,0 +1,108 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util; + +use Composer\Config; +use Composer\Downloader\TransportException; +use Composer\IO\IOInterface; + +/** + * @internal + * @readonly + */ +final class Forgejo +{ + /** @var IOInterface */ + private $io; + /** @var Config */ + private $config; + /** @var HttpDownloader */ + private $httpDownloader; + + /** + * @param IOInterface $io + * @param Config $config + * @param HttpDownloader $httpDownloader + */ + public function __construct(IOInterface $io, Config $config, HttpDownloader $httpDownloader) + { + $this->io = $io; + $this->config = $config; + $this->httpDownloader = $httpDownloader; + } + + /** + * Authorizes a Forgejo domain interactively + * + * @param string $originUrl The host this Forgejo instance is located at + * @param string $message The reason this authorization is required + * @throws \RuntimeException + * @throws TransportException|\Exception + * @return bool true on success + */ + public function authorizeOAuthInteractively(string $originUrl, ?string $message = null): bool + { + if ($message !== null) { + $this->io->writeError($message); + } + + $url = 'https://'.$originUrl.'/user/settings/applications'; + $this->io->writeError('Setup a personal access token with repository:read permissions on:'); + $this->io->writeError($url); + $localAuthConfig = $this->config->getLocalAuthConfigSource(); + $this->io->writeError(sprintf('Tokens will be stored in plain text in "%s" for future use by Composer.', ($localAuthConfig !== null ? $localAuthConfig->getName() . ' OR ' : '') . $this->config->getAuthConfigSource()->getName())); + $this->io->writeError('For additional information, check https://getcomposer.org/doc/articles/authentication-for-private-packages.md#forgejo-token'); + + $storeInLocalAuthConfig = false; + if ($localAuthConfig !== null) { + $storeInLocalAuthConfig = $this->io->askConfirmation('A local auth config source was found, do you want to store the token there?', true); + } + + $username = trim((string) $this->io->ask('Username: ')); + $token = trim((string) $this->io->askAndHideAnswer('Token (hidden): ')); + + $addTokenManually = sprintf('You can also add it manually later by using "composer config --global --auth forgejo-token.%s "', $originUrl); + if ($token === '' || $username === '') { + $this->io->writeError('No username/token given, aborting.'); + $this->io->writeError($addTokenManually); + + return false; + } + + $this->io->setAuthentication($originUrl, $username, $token); + + try { + $this->httpDownloader->get('https://'. $originUrl . '/api/v1/version', [ + 'retry-auth-failure' => false, + ]); + } catch (TransportException $e) { + if (in_array($e->getCode(), [403, 401, 404], true)) { + $this->io->writeError('Invalid access token provided.'); + $this->io->writeError($addTokenManually); + + return false; + } + + throw $e; + } + + // store value in local/user config + $authConfigSource = $storeInLocalAuthConfig && $localAuthConfig !== null ? $localAuthConfig : $this->config->getAuthConfigSource(); + $this->config->getConfigSource()->removeConfigSetting('forgejo-token.'.$originUrl); + $authConfigSource->addConfigSetting('forgejo-token.'.$originUrl, ['username' => $username, 'token' => $token]); + + $this->io->writeError('Token stored successfully.'); + + return true; + } +} diff --git a/src/Composer/Util/ForgejoRepositoryData.php b/src/Composer/Util/ForgejoRepositoryData.php index 2a45e238d573..6a442898f3c9 100644 --- a/src/Composer/Util/ForgejoRepositoryData.php +++ b/src/Composer/Util/ForgejoRepositoryData.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Util; /** diff --git a/src/Composer/Util/ForgejoUrl.php b/src/Composer/Util/ForgejoUrl.php index 263adada56f2..fc67bff1b36c 100644 --- a/src/Composer/Util/ForgejoUrl.php +++ b/src/Composer/Util/ForgejoUrl.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Util; use Composer\Pcre\Preg; diff --git a/tests/Composer/Test/ConfigTest.php b/tests/Composer/Test/ConfigTest.php index 1f35fbd6e078..e298ee5478d8 100644 --- a/tests/Composer/Test/ConfigTest.php +++ b/tests/Composer/Test/ConfigTest.php @@ -416,6 +416,7 @@ public function testGetDefaultsToAnEmptyArray(): void 'github-oauth', 'gitlab-oauth', 'gitlab-token', + 'forgejo-token', 'http-basic', 'bearer', ]; diff --git a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php index a107c11a3fe2..06312f197715 100644 --- a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Test\Repository\Vcs; use Composer\Config; diff --git a/tests/Composer/Test/Util/ForgejoTest.php b/tests/Composer/Test/Util/ForgejoTest.php new file mode 100644 index 000000000000..b0ce9086b9ab --- /dev/null +++ b/tests/Composer/Test/Util/ForgejoTest.php @@ -0,0 +1,133 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Util; + +use Composer\Test\TestCase; +use Composer\Util\Forgejo; +use Composer\Util\GitHub; + +class ForgejoTest extends TestCase +{ + /** @var string */ + private $username = 'username'; + /** @var string */ + private $accessToken = 'access-token'; + /** @var string */ + private $message = 'mymessage'; + /** @var string */ + private $origin = 'codeberg.org'; + + public function testUsernamePasswordAuthenticationFlow(): void + { + $io = $this->getIOMock(); + $io->expects([ + ['text' => $this->message], + ['ask' => 'Username: ', 'reply' => $this->username], + ['ask' => 'Token (hidden): ', 'reply' => $this->accessToken], + ]); + + $httpDownloader = $this->getHttpDownloaderMock(); + $httpDownloader->expects( + [['url' => sprintf('https://%s/api/v1/version', $this->origin), 'body' => '{}']], + true + ); + + $config = $this->getConfigMock(); + $config + ->expects($this->exactly(2)) + ->method('getAuthConfigSource') + ->willReturn($this->getAuthJsonMock()) + ; + $config + ->expects($this->once()) + ->method('getConfigSource') + ->willReturn($this->getConfJsonMock()) + ; + + $forgejo = new Forgejo($io, $config, $httpDownloader); + + self::assertTrue($forgejo->authorizeOAuthInteractively($this->origin, $this->message)); + } + + public function testUsernamePasswordFailure(): void + { + $io = $this->getIOMock(); + $io->expects([ + ['ask' => 'Username: ', 'reply' => $this->username], + ['ask' => 'Token (hidden): ', 'reply' => $this->accessToken], + ]); + + $httpDownloader = $this->getHttpDownloaderMock(); + $httpDownloader->expects( + [['url' => sprintf('https://%s/api/v1/version', $this->origin), 'status' => 404]], + true + ); + + $config = $this->getConfigMock(); + $config + ->expects($this->exactly(1)) + ->method('getAuthConfigSource') + ->willReturn($this->getAuthJsonMock()) + ; + + $forgejo = new Forgejo($io, $config, $httpDownloader); + + self::assertFalse($forgejo->authorizeOAuthInteractively($this->origin)); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config + */ + private function getConfigMock() + { + return $this->getMockBuilder('Composer\Config')->getMock(); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config\JsonConfigSource + */ + private function getAuthJsonMock() + { + $authjson = $this + ->getMockBuilder('Composer\Config\JsonConfigSource') + ->disableOriginalConstructor() + ->getMock() + ; + $authjson + ->expects($this->atLeastOnce()) + ->method('getName') + ->willReturn('auth.json') + ; + + return $authjson; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config\JsonConfigSource + */ + private function getConfJsonMock() + { + $confjson = $this + ->getMockBuilder('Composer\Config\JsonConfigSource') + ->disableOriginalConstructor() + ->getMock() + ; + $confjson + ->expects($this->atLeastOnce()) + ->method('removeConfigSetting') + ->with('forgejo-token.'.$this->origin) + ; + + return $confjson; + } +} diff --git a/tests/Composer/Test/Util/ForgejoUrlTest.php b/tests/Composer/Test/Util/ForgejoUrlTest.php index 2fc4889a0813..be466be6d69e 100644 --- a/tests/Composer/Test/Util/ForgejoUrlTest.php +++ b/tests/Composer/Test/Util/ForgejoUrlTest.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Test\Util; use Composer\Test\TestCase; From 54fe4d3dd396cf7eaf98b6d5ca5fd2dfca89ea43 Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Mon, 10 Feb 2025 19:24:28 +0000 Subject: [PATCH 3/5] Forgejo: fix tag parsing --- src/Composer/Repository/Vcs/ForgejoDriver.php | 2 +- tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Composer/Repository/Vcs/ForgejoDriver.php b/src/Composer/Repository/Vcs/ForgejoDriver.php index 82c89ac8148b..108de4db1f54 100644 --- a/src/Composer/Repository/Vcs/ForgejoDriver.php +++ b/src/Composer/Repository/Vcs/ForgejoDriver.php @@ -130,7 +130,7 @@ public function getTags(): array $response = $this->getContents($resource); $tagsData = $response->decodeJson(); foreach ($tagsData as $tag) { - $tags[$tag['tag_name']] = $tag['commit']['sha']; + $tags[$tag['name']] = $tag['commit']['sha']; } $resource = $this->getNextPage($response); diff --git a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php index 06312f197715..67b0e5dfa1db 100644 --- a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php @@ -130,7 +130,7 @@ public function testGetTags(): void 'clone_url' => 'https://codeberg.org/acme/repo.git' ])], ['url' => 'https://codeberg.org/api/v1/repos/acme/repo/tags?per_page=100', 'body' => (string) json_encode([ - ['tag_name' => '1.0', 'commit' => ['sha' => 'SOMESHA']] + ['name' => '1.0', 'commit' => ['sha' => 'SOMESHA']] ])] ], true From b3f3f713d7de112fba2c702caacad246b04fde41 Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Mon, 24 Feb 2025 13:10:35 +0000 Subject: [PATCH 4/5] Forgejo: add config options to JSON schema --- res/composer-schema.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/res/composer-schema.json b/res/composer-schema.json index efe7a5e59b8b..9e2d60f43d74 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -513,6 +513,24 @@ "type": "string" } }, + "forgejo-token": { + "type": "object", + "description": "An object of domain name => forgejo username/access token, typically {\"codeberg.org\":{\"username\": \"\", \"token\": \"\"}}.", + "additionalProperties": { + "type": ["object"], + "required": ["username", "token"], + "properties": { + "username": { + "type": "string", + "description": "The username used for Forgejo authentication" + }, + "token": { + "type": "string", + "description": "The access token used for Forgejo authentication" + } + } + } + }, "disable-tls": { "type": "boolean", "description": "Defaults to `false`. If set to true all HTTPS URLs will be tried with HTTP instead and no network level encryption is performed. Enabling this is a security risk and is NOT recommended. The better way is to enable the php_openssl extension in php.ini." @@ -648,6 +666,13 @@ "type": "string" } }, + "forgejo-domains": { + "type": "array", + "description": "A list of domains to use in forgejo mode. This is used for custom Forgejo setups, defaults to [\"codeberg.org\"].", + "items": { + "type": "string" + } + }, "bitbucket-oauth": { "type": "object", "description": "An object of domain name => {\"consumer-key\": \"...\", \"consumer-secret\": \"...\"}.", From 225adb5b62f84640591836d95eb82dde9ac59d1b Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Wed, 2 Apr 2025 16:40:13 +0100 Subject: [PATCH 5/5] Cleanup getSource method --- src/Composer/Repository/Vcs/ForgejoDriver.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Composer/Repository/Vcs/ForgejoDriver.php b/src/Composer/Repository/Vcs/ForgejoDriver.php index 108de4db1f54..286ac2cc858f 100644 --- a/src/Composer/Repository/Vcs/ForgejoDriver.php +++ b/src/Composer/Repository/Vcs/ForgejoDriver.php @@ -199,15 +199,8 @@ public function getSource(string $identifier): array if ($this->gitDriver !== null) { return $this->gitDriver->getSource($identifier); } - if ($this->repositoryData->isPrivate) { - // Private Forgejo repositories should be accessed using the - // SSH version of the URL. - $url = $this->repositoryData->sshUrl; - } else { - $url = $this->getUrl(); - } - return ['type' => 'git', 'url' => $url, 'reference' => $identifier]; + return ['type' => 'git', 'url' => $this->getUrl(), 'reference' => $identifier]; } public function getUrl(): string