diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7e4cdb1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Added + +- Private repository support via HTTP Basic credentials. Pass a `Credentials` value object (`Credentials::make($token)` or `new Credentials($username, $token)`) to the new fluent `RemoteRepository::withCredentials()` method. When a server answers with HTTP 401, the same request is retried once with an `Authorization: Basic ...` header on both ref discovery and `git-upload-pack` calls; a repeated rejection throws the new `AuthenticationFailedException`. Without credentials, the existing failure message now hints that the repository may be private and that credentials can be provided. diff --git a/src/AuthenticationFailedException.php b/src/AuthenticationFailedException.php new file mode 100644 index 0000000..c3f36f0 --- /dev/null +++ b/src/AuthenticationFailedException.php @@ -0,0 +1,9 @@ +username . ':' . $this->token); + } +} diff --git a/src/RemoteRepository.php b/src/RemoteRepository.php index 3442d68..2739882 100644 --- a/src/RemoteRepository.php +++ b/src/RemoteRepository.php @@ -5,12 +5,17 @@ namespace GitReader; /** - * Read-only client for public Git repositories over smart HTTPS. + * Read-only client for Git repositories over smart HTTPS. * * Speaks the classic (protocol v0) wire format, which every major host still * serves by default: ref discovery followed by a single shallow * (`deepen 1`) upload-pack request. No negotiation, no thin packs, no side-band. * + * Public repositories work out of the box. Private repositories are supported + * by attaching HTTP Basic credentials (`withCredentials()`): a request answered + * with HTTP 401 is retried once with an Authorization header, and a repeated + * rejection throws AuthenticationFailedException. + * * @internal */ final class RemoteRepository @@ -24,6 +29,7 @@ final class RemoteRepository public function __construct( private readonly string $repositoryUrl, private readonly SmartHttpTransport $transport = new SmartHttpTransport(), + private readonly ?Credentials $credentials = null, ) { if (! preg_match('#^https?://#i', $repositoryUrl)) { throw new GitException(sprintf( @@ -33,6 +39,15 @@ public function __construct( } } + /** + * Returns a copy of this client that authenticates with the given HTTP + * Basic credentials. Passing null removes any credentials. + */ + public function withCredentials(?Credentials $credentials): self + { + return new self($this->repositoryUrl, $this->transport, $credentials); + } + /** * Discover refs once per instance. */ @@ -43,19 +58,21 @@ public function refs(): RefAdvertisement } $base = rtrim($this->repositoryUrl, '/'); - $response = $this->transport->get( + $response = $this->requestWithAuthRetry( + 'GET', $base . '/info/refs?service=git-upload-pack', + null, ['Accept' => 'application/x-git-upload-pack-advertisement'], ); $this->uploadBaseUrl = $response->effectiveUrl; $status = $response->status; - if ($status === 401 || $status === 403) { + if ($status === 403) { $response->close(); throw new GitException(sprintf( - 'The repository [%s] requires authentication (%d). Private repositories are not supported yet.', + 'The repository [%s] refused access (%d). Check the URL and that your credentials grant read access.', $this->repositoryUrl, $status, )); @@ -179,19 +196,18 @@ public function fetchTipSnapshot(string $commitSha, int $memoryBudgetBytes = 64 $path = preg_replace('#/+info/refs$#', '', (string) ($parts['path'] ?? '')) ?? ''; $base .= $path; - $response = $this->transport->post( - $base . '/git-upload-pack', - $request, - [ - 'Content-Type' => 'application/x-git-upload-pack-request', - 'Accept' => 'application/x-git-upload-pack-result', - ], - ); + $response = $this->requestWithAuthRetry('POST', $base . '/git-upload-pack', $request, [ + 'Content-Type' => 'application/x-git-upload-pack-request', + 'Accept' => 'application/x-git-upload-pack-result', + ]); - if ($response->status === 401 || $response->status === 403) { + if ($response->status === 403) { $response->close(); - throw new GitException('The remote rejected the fetch request; authentication is not supported yet.'); + throw new GitException(sprintf( + 'The remote refused the fetch request (%d); the provided credentials may lack read access.', + $response->status, + )); } if ($response->status !== 200) { @@ -254,6 +270,69 @@ public function fetchTipSnapshot(string $commitSha, int $memoryBudgetBytes = 64 return PackObjectStore::import($packPath, memoryBudgetBytes: $memoryBudgetBytes); } + /** + * Performs a transport request and answers a single HTTP 401 challenge + * with the configured credentials, retrying the exact same request once. + * + * The first attempt is sent without an Authorization header so servers + * that ignore credentials on public repositories keep working; a 401 + * response triggers exactly one authorized retry. + * + * @param array $headers + */ + private function requestWithAuthRetry(string $method, string $url, ?string $body, array $headers): TransportResponse + { + $authorized = false; + $response = $this->send($method, $url, $body, $headers, $authorized); + + if ($response->status !== 401) { + return $response; + } + + $response->close(); + + if (! $authorized) { + if ($this->credentials === null) { + throw new GitException(sprintf( + 'The repository [%s] requires authentication (%d) (the repository may be private; provide credentials).', + $this->repositoryUrl, + $response->status, + )); + } + + $authorized = true; + $response = $this->send($method, $url, $body, $headers, $authorized); + + if ($response->status !== 401) { + return $response; + } + + $response->close(); + } + + throw new AuthenticationFailedException(sprintf( + 'Authentication failed for repository [%s]: the server rejected the provided credentials (HTTP %d).', + $this->repositoryUrl, + $response->status, + )); + } + + /** + * @param array $headers + */ + private function send(string $method, string $url, ?string $body, array $headers, bool $authorized): TransportResponse + { + if ($authorized && $this->credentials instanceof Credentials) { + $headers['Authorization'] = $this->credentials->authorizationHeader(); + } + + if ($method === 'POST') { + return $this->transport->post($url, (string) $body, $headers); + } + + return $this->transport->get($url, $headers); + } + public static function normalizeRepositoryUrl(string $url): string { $url = trim($url); diff --git a/tests/Feature/WireEndToEndTest.php b/tests/Feature/WireEndToEndTest.php index 41cf25c..27af132 100644 --- a/tests/Feature/WireEndToEndTest.php +++ b/tests/Feature/WireEndToEndTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use GitReader\AuthenticationFailedException; +use GitReader\Credentials; +use GitReader\GitException; use GitReader\ProtocolException; use GitReader\RemoteRepository; use GitReader\RepositoryNotFoundException; @@ -131,3 +134,90 @@ function withWireServer(): array stopWireServer([$port, $process]); } })->throws(RepositoryNotFoundException::class); + +it('retries a 401 challenge with credentials and fetches a private repository end to end', function (): void { + [$port, $process] = startWireServer(); + + try { + $meta = json_decode((string) file_get_contents(WIRE_FIXTURES . '/meta.json'), true); + $head = is_array($meta) && is_string($meta['head'] ?? null) ? $meta['head'] : ''; + + $repo = (new RemoteRepository('http://127.0.0.1:' . $port . '/private.git')) + ->withCredentials(Credentials::make('secret-token')); + + $resolved = $repo->resolveRef('main'); + + expect($resolved->sha)->toBe($head) + ->and($resolved->name)->toBe('refs/heads/main'); + + $store = $repo->fetchTipSnapshot($resolved->sha); + + $rootTree = $store->commitTreeSha($resolved->sha); + $paths = array_map(fn (array $entry): string => (string) $entry['path'], $store->flattenTree($rootTree)); + + expect($paths)->toContain('README.md'); + + $store->cleanup(); + } finally { + stopWireServer([$port, $process]); + } +}); + +it('throws AuthenticationFailedException when the server rejects the provided token twice', function (): void { + [$port, $process] = startWireServer(); + + try { + $repo = (new RemoteRepository('http://127.0.0.1:' . $port . '/private.git')) + ->withCredentials(Credentials::make('wrong-token')); + + try { + $repo->refs(); + + throw new RuntimeException('Expected AuthenticationFailedException.'); + } catch (AuthenticationFailedException $exception) { + expect($exception->getMessage()) + ->toContain(sprintf('Authentication failed for repository [http://127.0.0.1:%d/private.git]', $port)); + expect($exception->getMessage())->not->toContain('wrong-token'); + } + } finally { + stopWireServer([$port, $process]); + } +}); + +it('keeps the unauthenticated failure path with a private repository hint', function (): void { + [$port, $process] = startWireServer(); + + try { + $repo = new RemoteRepository('http://127.0.0.1:' . $port . '/locked.git'); + + try { + $repo->refs(); + + throw new RuntimeException('Expected GitException.'); + } catch (GitException $exception) { + expect($exception)->toBeInstanceOf(GitException::class) + ->and($exception->getMessage())->toContain('the repository may be private; provide credentials'); + } + } finally { + stopWireServer([$port, $process]); + } +}); + +it('sends the Authorization header value derived from the credentials', function (): void { + [$port, $process] = startWireServer(); + + try { + // The fixture only answers /private.git when it receives exactly + // "Basic " . base64_encode("x-access-token:secret-token"), so a + // successful ref discovery proves the header value is correct. + $repo = (new RemoteRepository('http://127.0.0.1:' . $port . '/private.git')) + ->withCredentials(Credentials::make('secret-token')); + + $meta = json_decode((string) file_get_contents(WIRE_FIXTURES . '/meta.json'), true); + $head = is_array($meta) && is_string($meta['head'] ?? null) ? $meta['head'] : ''; + + expect($repo->refs()->shaFor('refs/heads/main') ?? '')->toBe($head); + } finally { + stopWireServer([$port, $process]); + } +}); diff --git a/tests/Fixtures/Wire/router.php b/tests/Fixtures/Wire/router.php index 2f4362f..41ef9fc 100644 --- a/tests/Fixtures/Wire/router.php +++ b/tests/Fixtures/Wire/router.php @@ -11,9 +11,28 @@ $requestUri = is_string($_SERVER['REQUEST_URI'] ?? null) ? $_SERVER['REQUEST_URI'] : '/'; $uri = parse_url($requestUri, PHP_URL_PATH) ?? '/'; $query = $_GET; +$authorization = is_string($_SERVER['HTTP_AUTHORIZATION'] ?? null) ? $_SERVER['HTTP_AUTHORIZATION'] : ''; +$validAuthorization = 'Basic ' . base64_encode('x-access-token:secret-token'); header('Content-Type: text/plain'); +/** + * Answers 401 unless the request carries the expected HTTP Basic credentials. + */ +function requireFixtureAuth(string $authorization, string $validAuthorization): void +{ + if ($authorization === $validAuthorization) { + return; + } + + header('WWW-Authenticate: Basic realm="wire-fixture"'); + http_response_code(401); + + echo $authorization === '' ? 'authentication required' : 'invalid credentials'; + + exit; +} + if ($uri === '/fixture.git/info/refs') { if (($query['service'] ?? '') !== 'git-upload-pack') { http_response_code(400); @@ -29,6 +48,44 @@ exit; } +if ($uri === '/private.git/info/refs') { + if (($query['service'] ?? '') !== 'git-upload-pack') { + http_response_code(400); + + exit; + } + + requireFixtureAuth($authorization, $validAuthorization); + + header('Content-Type: application/x-git-upload-pack-advertisement'); + header('Cache-Control: no-cache'); + + echo file_get_contents($fixtures . '/advertisement.bin'); + + exit; +} + +if ($uri === '/private.git/git-upload-pack' && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + requireFixtureAuth($authorization, $validAuthorization); + + header('Content-Type: application/x-git-upload-pack-result'); + header('Cache-Control: no-cache'); + + echo file_get_contents($fixtures . '/pack-response.bin'); + + exit; +} + +if ($uri === '/locked.git/info/refs') { + if (($query['service'] ?? '') !== 'git-upload-pack') { + http_response_code(400); + + exit; + } + + requireFixtureAuth($authorization, $validAuthorization); +} + if ($uri === '/fixture.git/git-upload-pack' && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { header('Content-Type: application/x-git-upload-pack-result'); header('Cache-Control: no-cache'); diff --git a/tests/Unit/CredentialsTest.php b/tests/Unit/CredentialsTest.php new file mode 100644 index 0000000..55e178c --- /dev/null +++ b/tests/Unit/CredentialsTest.php @@ -0,0 +1,26 @@ +authorizationHeader()) + ->toBe('Basic ' . base64_encode('x-access-token:secret-token')); +}); + +it('defaults the username to x-access-token', function (): void { + $credentials = Credentials::make('secret-token'); + + expect($credentials->username)->toBe('x-access-token') + ->and($credentials->token)->toBe('secret-token') + ->and($credentials->authorizationHeader())->toBe('Basic ' . base64_encode('x-access-token:secret-token')); +}); + +it('supports custom usernames and escapes them into the header', function (): void { + $credentials = Credentials::make('p@ss:word', 'octocat'); + + expect($credentials->authorizationHeader())->toBe('Basic ' . base64_encode('octocat:p@ss:word')); +});