Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions src/AuthenticationFailedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace GitReader;

final class AuthenticationFailedException extends GitException
{
}
33 changes: 33 additions & 0 deletions src/Credentials.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace GitReader;

/**
* HTTP Basic credentials for private repositories.
*
* The token never appears in exception messages; only the derived
* Authorization header value is ever transmitted.
*/
final class Credentials
{
public function __construct(
public readonly string $username,
public readonly string $token,
) {
}

public static function make(string $token, string $username = 'x-access-token'): self
{
return new self($username, $token);
}

/**
* Value for the Authorization header, e.g. "Basic base64(user:token)".
*/
public function authorizationHeader(): string
{
return 'Basic ' . base64_encode($this->username . ':' . $this->token);
}
}
107 changes: 93 additions & 14 deletions src/RemoteRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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.
*/
Expand All @@ -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,
));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, string> $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<string, string> $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);
Expand Down
90 changes: 90 additions & 0 deletions tests/Feature/WireEndToEndTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
}
});
57 changes: 57 additions & 0 deletions tests/Fixtures/Wire/router.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down
Loading
Loading