feat: improve pure PHP Git protocol validation and negotiation

This commit is contained in:
hanyixuanten
2026-08-14 19:31:41 +08:00
parent 03b708916f
commit a41884f7b8
10 changed files with 928 additions and 71 deletions
+9 -4
View File
@@ -12,18 +12,23 @@ This is a PHP Smart HTTP Git server with no dependency manager or generated buil
- `schema.mysql.sql` and `migration.repository-ownership.mysql.sql` define database changes.
- `config.php.sample` documents settings; local `config.php` must never be committed.
- `repos/` contains runtime bare repositories. `README.md` and `usage.md` document deployment.
There is no dedicated automated test directory.
- `tests/` contains the dependency-free protocol regression suite and disposable repository fixtures.
## Build, Test, and Development Commands
PHP is interpreted directly, so there is no build step. Run syntax checks from the repository root:
PHP is interpreted directly, so there is no build step. Run the complete regression suite from the repository root:
```sh
php tests/run.php
```
Run syntax checks across production and test code:
```sh
php -l index.php
php -l install.php
php -l manage.php
for file in lib/*.php operations/*.php; do php -l "$file" || exit 1; done
for file in lib/*.php operations/*.php tests/*.php; do php -l "$file" || exit 1; done
```
Check required runtime support with `php -m | grep pdo_mysql`. For a local smoke test, use a disposable `config.php` and PHP-capable web server, then exercise the UI and Git endpoints with `git ls-remote` or `git clone`.
+58 -9
View File
@@ -359,25 +359,45 @@ function git_object_store_apply_delta($base, $delta, $max_result_bytes=0) {
function git_object_store_links($object) {
$links = array();
if ($object['type'] === 'commit') {
foreach (preg_split('~\n~', $object['body']) as $line) {
if ($line === '') {
break;
}
if (preg_match('~^(?:tree|parent) ([0-9a-f]{40})$~D', $line, $matches)) {
$headers = strstr($object['body'], "\n\n", TRUE);
if ($headers === FALSE) {
return FALSE;
}
$lines = explode("\n", $headers);
if (empty($lines) || !preg_match('~^tree ([0-9a-f]{40})$~D', $lines[0], $matches)) {
return FALSE;
}
$links[] = $matches[1];
foreach (array_slice($lines, 1) as $line) {
if (strpos($line, 'parent ') === 0) {
if (!preg_match('~^parent ([0-9a-f]{40})$~D', $line, $matches)) {
return FALSE;
}
$links[] = $matches[1];
}
}
} else if ($object['type'] === 'tag') {
if (preg_match('~^object ([0-9a-f]{40})$~m', $object['body'], $matches)) {
$links[] = $matches[1];
$headers = strstr($object['body'], "\n\n", TRUE);
if ($headers === FALSE) {
return FALSE;
}
$lines = explode("\n", $headers);
if (count($lines) < 3
|| !preg_match('~^object ([0-9a-f]{40})$~D', $lines[0], $object_match)
|| !preg_match('~^type (commit|tree|blob|tag)$~D', $lines[1])
|| !preg_match('~^tag .+$~D', $lines[2])) {
return FALSE;
}
$links[] = $object_match[1];
} else if ($object['type'] === 'tree') {
$position = 0;
$length = strlen($object['body']);
while ($position < $length) {
$separator = strpos($object['body'], "\0", $position);
if ($separator === FALSE || $separator + 21 > $length
|| !preg_match('~^[0-7]+ [^/]+$~D', substr($object['body'], $position, $separator - $position))) {
|| !preg_match(
'~^(?:40000|100644|100755|120000|160000) [^/\x00]+$~D',
substr($object['body'], $position, $separator - $position))) {
return FALSE;
}
$links[] = bin2hex(substr($object['body'], $separator + 1, 20));
@@ -388,6 +408,35 @@ function git_object_store_links($object) {
return $links;
}
function git_object_store_validate_links(&$store, $object, $links) {
if ($object['type'] === 'commit') {
foreach ($links as $index => $oid) {
$linked = git_object_store_read($store, $oid);
if ($linked === FALSE
|| ($index === 0 && $linked['type'] !== 'tree')
|| ($index > 0 && $linked['type'] !== 'commit')) {
return FALSE;
}
}
} else if ($object['type'] === 'tag') {
if (!preg_match('~^type (commit|tree|blob|tag)$~m', $object['body'], $matches)) {
return FALSE;
}
$linked = git_object_store_read($store, $links[0]);
if ($linked === FALSE || $linked['type'] !== $matches[1]) {
return FALSE;
}
} else if ($object['type'] === 'tree') {
foreach ($links as $oid) {
if (git_object_store_read($store, $oid) === FALSE) {
return FALSE;
}
}
}
return TRUE;
}
function git_object_store_collect(&$store, $roots) {
$objects = array();
$pending = array_values($roots);
@@ -401,7 +450,7 @@ function git_object_store_collect(&$store, $roots) {
return FALSE;
}
$links = git_object_store_links($object);
if ($links === FALSE) {
if ($links === FALSE || !git_object_store_validate_links($store, $object, $links)) {
return FALSE;
}
$objects[$oid] = $object;
+34 -11
View File
@@ -438,6 +438,9 @@ function git_receive_pack_commit_updates($git_path, $locks) {
foreach ($locks as $lock) {
$updates[] = $lock['update'];
}
$packed_path = get_safe_file_path($git_path, '/packed-refs');
$packed_contents = $packed_path === FALSE ? NULL : @file_get_contents($packed_path);
$committed = array();
if (!git_receive_pack_remove_packed_refs($git_path, $updates)) {
return FALSE;
}
@@ -447,17 +450,37 @@ function git_receive_pack_commit_updates($git_path, $locks) {
$lock['file'] = NULL;
if ($lock['update']['new'] === str_repeat('0', 40)) {
if (is_file($lock['path']) && !@unlink($lock['path'])) {
git_receive_pack_release_locks($locks);
return FALSE;
break;
}
@unlink($lock['lock_path']);
} else if (!@rename($lock['lock_path'], $lock['path'])) {
git_receive_pack_release_locks($locks);
return FALSE;
break;
}
$committed[] = $lock['update'];
}
unset($lock);
return TRUE;
if (count($committed) === count($locks)) {
return TRUE;
}
git_receive_pack_release_locks($locks);
foreach ($committed as $update) {
$path = $git_path.'/'.$update['ref'];
if ($update['old'] === str_repeat('0', 40)) {
@unlink($path);
continue;
}
if (!is_dir(dirname($path))) {
@mkdir(dirname($path), 0777, TRUE);
}
@file_put_contents($path, $update['old']."\n", LOCK_EX);
}
if ($packed_contents !== NULL) {
@file_put_contents($git_path.'/packed-refs', $packed_contents, LOCK_EX);
} else {
@unlink($git_path.'/packed-refs');
}
return FALSE;
}
function git_receive_pack_status($capabilities, $updates, $unpack_ok, $message) {
@@ -526,7 +549,7 @@ function git_receive_pack_rpc_native($repository, $input) {
$objects = array();
if ($store === FALSE) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'unsupported repository format');
return TRUE;
return FALSE;
}
if ($needs_pack) {
$pack = stream_get_contents($input);
@@ -537,7 +560,7 @@ function git_receive_pack_rpc_native($repository, $input) {
(int) $repository['options']['max_pack_objects']);
if ($objects === FALSE) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'invalid pack');
return TRUE;
return FALSE;
}
}
@@ -550,27 +573,27 @@ function git_receive_pack_rpc_native($repository, $input) {
|| (strpos($update['ref'], 'refs/heads/') === 0
&& $store['objects'][$update['new']]['type'] !== 'commit')) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'invalid object graph');
return TRUE;
return FALSE;
}
if (strpos($update['ref'], 'refs/heads/') === 0
&& $update['old'] !== $zero
&& !$repository['options']['allow_non_fast_forward']
&& !git_receive_pack_is_ancestor($store, $update['old'], $update['new'])) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'non-fast-forward');
return TRUE;
return FALSE;
}
}
$locks = git_receive_pack_lock_updates($repository['path'], $commands['updates']);
if ($locks === FALSE) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'stale or locked ref');
return TRUE;
return FALSE;
}
if (!git_receive_pack_write_objects($repository['path'], $objects)
|| !git_receive_pack_commit_updates($repository['path'], $locks)) {
git_receive_pack_release_locks($locks);
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'repository update failed');
return TRUE;
return FALSE;
}
git_receive_pack_status($commands['capabilities'], $commands['updates'], TRUE, '');
+37 -3
View File
@@ -2,8 +2,12 @@
function git_upload_pack_capabilities($git_path) {
$capabilities = array(
'multi_ack_detailed',
'no-done',
'side-band-64k',
'ofs-delta',
'thin-pack',
'include-tag',
'agent=php-git-server/1');
$head = resolve_ref($git_path, 'HEAD');
@@ -49,9 +53,17 @@ function git_upload_pack_advertise_native($repository) {
function git_upload_pack_parse_request($input) {
$request = array('wants' => array(), 'haves' => array(), 'capabilities' => array());
$first_want = TRUE;
$want_phase_done = FALSE;
$complete = FALSE;
while (($packet = git_protocol_read_packet($input)) !== FALSE) {
if ($packet['type'] === 'flush') {
if ($want_phase_done && !empty($request['haves'])
&& isset($request['capabilities']['no-done'])) {
$complete = TRUE;
break;
}
$want_phase_done = TRUE;
continue;
}
if ($packet['type'] !== 'data') {
@@ -70,13 +82,14 @@ function git_upload_pack_parse_request($input) {
} else if (preg_match('~^have ([0-9a-f]{40})$~D', $line, $matches)) {
$request['haves'][] = $matches[1];
} else if ($line === 'done') {
$complete = TRUE;
break;
} else {
return FALSE;
}
}
if ($packet === FALSE || empty($request['wants'])) {
if (!$complete || empty($request['wants'])) {
return FALSE;
}
return $request;
@@ -121,6 +134,21 @@ function git_upload_pack_rpc_native($repository, $input) {
if ($objects === FALSE) {
return FALSE;
}
if (isset($request['capabilities']['include-tag'])) {
foreach (get_repository_refs($repository['path']) as $ref) {
if (strpos($ref[0], 'refs/tags/') !== 0) {
continue;
}
$tag = git_object_store_read($store, $ref[1]);
if ($tag === FALSE || $tag['type'] !== 'tag') {
continue;
}
$links = git_object_store_links($tag);
if ($links !== FALSE && isset($objects[$links[0]])) {
$objects[$tag['oid']] = $tag;
}
}
}
$last_common = NULL;
foreach ($request['haves'] as $have) {
@@ -139,8 +167,14 @@ function git_upload_pack_rpc_native($repository, $input) {
return FALSE;
}
echo git_protocol_format_packet(
$last_common === NULL ? "NAK\n" : 'ACK '.$last_common."\n");
if ($last_common === NULL) {
echo git_protocol_format_packet("NAK\n");
} else if (isset($request['capabilities']['multi_ack_detailed'])) {
echo git_protocol_format_packet('ACK '.$last_common." common\n");
echo git_protocol_format_packet('ACK '.$last_common." ready\n");
} else {
echo git_protocol_format_packet('ACK '.$last_common."\n");
}
if (isset($request['capabilities']['side-band-64k'])) {
git_upload_pack_send_sideband(1, $pack);
echo '0000';
+111
View File
@@ -0,0 +1,111 @@
<?php
require_once dirname(__DIR__).'/lib/repository.php';
require_once dirname(__DIR__).'/lib/git_protocol.php';
require_once dirname(__DIR__).'/lib/git_object_store.php';
require_once dirname(__DIR__).'/lib/git_upload_pack.php';
require_once dirname(__DIR__).'/lib/git_receive_pack.php';
require_once dirname(__DIR__).'/lib/git_service.php';
if (!isset($GLOBALS['test_results'])) {
$GLOBALS['test_results'] = array('passed' => 0, 'failed' => 0);
}
function test_case($name, $callback) {
try {
$callback();
$GLOBALS['test_results']['passed'] += 1;
fwrite(STDOUT, "ok - ".$name."\n");
} catch (Throwable $error) {
$GLOBALS['test_results']['failed'] += 1;
fwrite(STDERR, "not ok - ".$name."\n ".$error->getMessage()."\n");
}
}
function test_fail($message) {
throw new RuntimeException($message);
}
function test_assert_true($value, $message='Expected value to be TRUE.') {
if ($value !== TRUE) {
test_fail($message);
}
}
function test_assert_false($value, $message='Expected value to be FALSE.') {
if ($value !== FALSE) {
test_fail($message);
}
}
function test_assert_same($expected, $actual, $message='') {
if ($expected !== $actual) {
$detail = 'Expected '.var_export($expected, TRUE).', got '.var_export($actual, TRUE).'.';
test_fail($message === '' ? $detail : $message.' '.$detail);
}
}
function test_assert_contains($needle, $haystack, $message='') {
if (strpos($haystack, $needle) === FALSE) {
$detail = 'Expected output to contain '.var_export($needle, TRUE).'.';
test_fail($message === '' ? $detail : $message.' '.$detail);
}
}
function test_capture_output($callback) {
ob_start();
$result = NULL;
$output = '';
try {
$result = $callback();
$output = ob_get_contents();
} finally {
ob_end_clean();
}
if ($result !== TRUE) {
test_fail('Protocol function returned failure.');
}
return $output;
}
function test_stream($contents) {
$stream = fopen('php://temp', 'w+b');
if ($stream === FALSE || fwrite($stream, $contents) !== strlen($contents)) {
test_fail('Unable to create test input stream.');
}
rewind($stream);
return $stream;
}
function test_remove_directory($path) {
if (is_link($path) || is_file($path)) {
@unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (scandir($path) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
test_remove_directory($path.DIRECTORY_SEPARATOR.$entry);
}
@rmdir($path);
}
function test_protocol_packets($buffer) {
$stream = test_stream($buffer);
$packets = array();
while (!feof($stream)) {
$position = ftell($stream);
$packet = git_protocol_read_packet($stream);
if ($packet === FALSE) {
fseek($stream, $position);
break;
}
$packets[] = $packet;
}
fclose($stream);
return $packets;
}
+120
View File
@@ -0,0 +1,120 @@
<?php
function git_fixture_create() {
$path = sys_get_temp_dir().'/php-git-server-test-'.bin2hex(random_bytes(8)).'.git';
if (!git_service_init_bare_repository($path)) {
test_remove_directory($path);
test_fail('Unable to initialize fixture repository.');
}
return $path;
}
function git_fixture_repository($path, $options=array()) {
return array(
'url' => '/fixture.git',
'path' => $path,
'options' => array_merge(repository_default_options(), $options));
}
function git_fixture_object($path, $type, $body, $write=TRUE) {
$contents = $type.' '.strlen($body)."\0".$body;
$oid = sha1($contents);
if ($write) {
$directory = $path.'/objects/'.substr($oid, 0, 2);
if (!is_dir($directory) && !mkdir($directory, 0777, TRUE)) {
test_fail('Unable to create fixture object directory.');
}
$compressed = gzcompress($contents);
if ($compressed === FALSE
|| file_put_contents($directory.'/'.substr($oid, 2), $compressed) !== strlen($compressed)) {
test_fail('Unable to write fixture object.');
}
}
return array('type' => $type, 'body' => $body, 'oid' => $oid);
}
function git_fixture_history($path, $messages) {
$blob = git_fixture_object($path, 'blob', "fixture\n");
$tree = git_fixture_object($path, 'tree', "100644 fixture.txt\0".hex2bin($blob['oid']));
$commits = array();
$parent = NULL;
foreach ($messages as $index => $message) {
$body = 'tree '.$tree['oid']."\n";
if ($parent !== NULL) {
$body .= 'parent '.$parent."\n";
}
$timestamp = 1700000000 + $index;
$body .= 'author Fixture <[email protected]> '.$timestamp." +0000\n";
$body .= 'committer Fixture <[email protected]> '.$timestamp." +0000\n\n".$message."\n";
$commit = git_fixture_object($path, 'commit', $body);
$commits[] = $commit['oid'];
$parent = $commit['oid'];
}
return $commits;
}
function git_fixture_tag($path, $target, $name='v1.0.0') {
$body = 'object '.$target."\n";
$body .= "type commit\n";
$body .= 'tag '.$name."\n";
$body .= "tagger Fixture <[email protected]> 1700000100 +0000\n\nfixture tag\n";
$tag = git_fixture_object($path, 'tag', $body);
return $tag['oid'];
}
function git_fixture_write_ref($path, $ref, $oid) {
$ref_path = $path.'/'.$ref;
if (!is_dir(dirname($ref_path)) && !mkdir(dirname($ref_path), 0777, TRUE)) {
test_fail('Unable to create fixture ref directory.');
}
if (file_put_contents($ref_path, $oid."\n") !== 41) {
test_fail('Unable to write fixture ref.');
}
}
function git_fixture_pack_objects($objects) {
$indexed = array();
foreach ($objects as $object) {
$indexed[$object['oid']] = $object;
}
$pack = git_object_store_build_pack($indexed);
if ($pack === FALSE) {
test_fail('Unable to build fixture pack.');
}
return $pack;
}
function git_fixture_pack($path, $oids) {
$store = git_object_store_create($path);
$objects = git_object_store_collect($store, $oids);
if ($objects === FALSE) {
test_fail('Unable to collect objects for fixture pack.');
}
return git_fixture_pack_objects(array_values($objects));
}
function git_fixture_remove_loose_objects($path) {
foreach (scandir($path.'/objects') as $entry) {
if (!preg_match('~^[0-9a-f]{2}$~D', $entry)) {
continue;
}
test_remove_directory($path.'/objects/'.$entry);
}
}
function git_fixture_has_object($path, $oid) {
$store = git_object_store_create($path);
return $store !== FALSE && git_object_store_read($store, $oid) !== FALSE;
}
function git_fixture_receive_input($updates, $pack='', $capabilities='report-status') {
$input = '';
foreach ($updates as $index => $update) {
$payload = $update['old'].' '.$update['new'].' '.$update['ref'];
if ($index === 0 && $capabilities !== '') {
$payload .= "\0".$capabilities;
}
$input .= git_protocol_format_packet($payload."\n");
}
return $input.'0000'.$pack;
}
+366
View File
@@ -0,0 +1,366 @@
<?php
require_once __DIR__.'/bootstrap.php';
require_once __DIR__.'/git_fixture.php';
function receive_test_run($fixture, $updates, $pack='', $options=array()) {
$result = receive_test_run_result($fixture, $updates, $pack, $options);
return $result[1];
}
function receive_test_run_result($fixture, $updates, $pack='', $options=array()) {
$input = test_stream(git_fixture_receive_input($updates, $pack));
ob_start();
try {
$result = git_receive_pack_rpc_native(git_fixture_repository($fixture, $options), $input);
$output = ob_get_contents();
} finally {
ob_end_clean();
fclose($input);
}
return array($result, $output);
}
test_case('receive-pack does not advertise atomic before crash recovery exists', function () {
test_assert_false(strpos(git_receive_pack_capabilities(), 'atomic') !== FALSE);
});
test_case('receive-pack accepts the first branch push', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
$pack = git_fixture_pack($fixture, array($history[0]));
git_fixture_remove_loose_objects($fixture);
test_assert_false(git_fixture_has_object($fixture, $history[0]));
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $history[0],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack ok\n", $output);
test_assert_contains("ok refs/heads/main\n", $output);
test_assert_same($history[0], resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack accepts a fast-forward update', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$output = receive_test_run($fixture, array(array(
'old' => $history[0],
'new' => $history[1],
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($history[1])));
test_assert_contains("unpack ok\n", $output);
test_assert_same($history[1], resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a non-fast-forward update', function () {
$fixture = git_fixture_create();
try {
$main = git_fixture_history($fixture, array('main'))[0];
$other = git_fixture_history($fixture, array('other'))[0];
git_fixture_write_ref($fixture, 'refs/heads/main', $main);
$output = receive_test_run($fixture, array(array(
'old' => $main,
'new' => $other,
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($other)));
test_assert_contains("unpack non-fast-forward\n", $output);
test_assert_contains("ng refs/heads/main non-fast-forward\n", $output);
test_assert_same($main, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack deletes an existing ref', function () {
$fixture = git_fixture_create();
try {
$oid = git_fixture_history($fixture, array('one'))[0];
git_fixture_write_ref($fixture, 'refs/heads/topic', $oid);
$output = receive_test_run($fixture, array(array(
'old' => $oid,
'new' => str_repeat('0', 40),
'ref' => 'refs/heads/topic')));
test_assert_contains("unpack ok\n", $output);
test_assert_contains("ok refs/heads/topic\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/topic')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack accepts an annotated tag', function () {
$fixture = git_fixture_create();
try {
$commit = git_fixture_history($fixture, array('one'))[0];
$tag = git_fixture_tag($fixture, $commit);
$pack = git_fixture_pack($fixture, array($tag));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $tag,
'ref' => 'refs/tags/v1.0.0')),
$pack);
test_assert_contains("unpack ok\n", $output);
test_assert_same($tag, resolve_ref($fixture, 'refs/tags/v1.0.0')[1]);
test_assert_true(git_fixture_has_object($fixture, $tag));
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a corrupt pack without changing refs', function () {
$fixture = git_fixture_create();
try {
$oid = git_fixture_history($fixture, array('one'))[0];
$pack = git_fixture_pack($fixture, array($oid));
git_fixture_remove_loose_objects($fixture);
$pack[strlen($pack) - 1] = chr(ord($pack[strlen($pack) - 1]) ^ 1);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $oid,
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid pack\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
test_assert_false(git_fixture_has_object($fixture, $oid));
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects an object graph with a missing object', function () {
$fixture = git_fixture_create();
try {
$missing_tree = str_repeat('a', 40);
$body = 'tree '.$missing_tree."\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\nmissing tree\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a commit without a tree header', function () {
$fixture = git_fixture_create();
try {
$body = "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\nmissing tree\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a malformed tree entry mode', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "fixture\n");
$tree = git_fixture_object($fixture, 'tree', "100600 fixture.txt\0".hex2bin($blob['oid']));
$body = 'tree '.$tree['oid']."\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\ninvalid tree\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($blob, $tree, $commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects an annotated tag without a declared type', function () {
$fixture = git_fixture_create();
try {
$commit = git_fixture_history($fixture, array('one'))[0];
$body = 'object '.$commit."\n";
$body .= "tag v1.0.0\n";
$body .= "tagger Fixture <[email protected]> 1700000100 +0000\n\ninvalid tag\n";
$tag = git_fixture_object($fixture, 'tag', $body);
$pack = git_fixture_pack_objects(array($tag));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $tag['oid'],
'ref' => 'refs/tags/v1.0.0')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/tags/v1.0.0')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a commit whose tree points to a blob', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "not a tree\n");
$body = 'tree '.$blob['oid']."\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\nwrong type\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($blob, $commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a malformed parent header', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "fixture\n");
$tree = git_fixture_object($fixture, 'tree', "100644 fixture.txt\0".hex2bin($blob['oid']));
$body = 'tree '.$tree['oid']."\n";
$body .= "parent not-an-object-id\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\ninvalid parent\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($blob, $tree, $commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a tag whose declared type does not match its target', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "fixture\n");
$body = 'object '.$blob['oid']."\n";
$body .= "type commit\n";
$body .= "tag v1.0.0\n";
$body .= "tagger Fixture <[email protected]> 1700000100 +0000\n\nwrong type\n";
$tag = git_fixture_object($fixture, 'tag', $body);
$pack = git_fixture_pack_objects(array($blob, $tag));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $tag['oid'],
'ref' => 'refs/tags/v1.0.0')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/tags/v1.0.0')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a stale old OID without changing the ref', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two', 'three'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$output = receive_test_run($fixture, array(array(
'old' => $history[0],
'new' => $history[2],
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($history[2])));
test_assert_contains("unpack stale or locked ref\n", $output);
test_assert_contains("ng refs/heads/main stale or locked ref\n", $output);
test_assert_same($history[1], resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack returns failure when refs are rejected', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$result = receive_test_run_result($fixture, array(array(
'old' => $history[0],
'new' => $history[1],
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($history[1])));
test_assert_false($result[0]);
test_assert_contains("ng refs/heads/main stale or locked ref\n", $result[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rolls back earlier refs when a later commit fails', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
git_fixture_write_ref($fixture, 'refs/heads/topic', $history[0]);
$updates = array(
array('old' => $history[0], 'new' => $history[1], 'ref' => 'refs/heads/main'),
array('old' => $history[0], 'new' => $history[1], 'ref' => 'refs/heads/topic'));
$locks = git_receive_pack_lock_updates($fixture, $updates);
test_assert_true(is_array($locks), 'Expected both refs to lock.');
$locks[1]['path'] = $fixture.'/missing/refs/heads/topic';
test_assert_false(git_receive_pack_commit_updates($fixture, $locks));
test_assert_same($history[0], resolve_ref($fixture, 'refs/heads/main')[1]);
test_assert_same($history[0], resolve_ref($fixture, 'refs/heads/topic')[1]);
} finally {
test_remove_directory($fixture);
}
});
+146
View File
@@ -0,0 +1,146 @@
<?php
require_once __DIR__.'/bootstrap.php';
require_once __DIR__.'/git_fixture.php';
test_case('upload-pack advertises refs and capabilities', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$output = test_capture_output(function () use ($fixture) {
return git_upload_pack_advertise_native(git_fixture_repository($fixture));
});
test_assert_contains($history[0].' HEAD', $output);
test_assert_contains($history[0].' refs/heads/main', $output);
test_assert_contains('side-band-64k', $output);
test_assert_contains('multi_ack_detailed', $output);
test_assert_contains('no-done', $output);
test_assert_contains('thin-pack', $output);
test_assert_contains('include-tag', $output);
test_assert_same('0000', substr($output, -4));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack returns NAK and a valid pack for clone', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$request = git_protocol_format_packet('want '.$history[0]."\n").'0000'
.git_protocol_format_packet("done\n");
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
$nak = git_protocol_format_packet("NAK\n");
test_assert_same($nak, substr($output, 0, strlen($nak)));
$pack = substr($output, strlen($nak));
test_assert_same('PACK', substr($pack, 0, 4));
test_assert_same(hash('sha1', substr($pack, 0, -20), TRUE), substr($pack, -20));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack ACKs a common commit and excludes its history', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$request = git_protocol_format_packet('want '.$history[1]."\n").'0000'
.git_protocol_format_packet('have '.$history[0]."\n")
.git_protocol_format_packet("done\n");
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
test_assert_same(
git_protocol_format_packet('ACK '.$history[0]."\n"),
substr($output, 0, 49));
test_assert_same('PACK', substr($output, 49, 4));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack rejects an unadvertised want', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('advertised', 'hidden'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$request = git_protocol_format_packet('want '.$history[1]."\n").'0000'
.git_protocol_format_packet("done\n");
$input = test_stream($request);
$result = NULL;
$output = '';
ob_start();
try {
$result = git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
$output = ob_get_contents();
} finally {
ob_end_clean();
fclose($input);
}
test_assert_false($result);
test_assert_same('', $output);
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack reports detailed common and ready acknowledgments', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$request = git_protocol_format_packet(
'want '.$history[1]." multi_ack_detailed no-done\n").'0000'
.git_protocol_format_packet('have '.$history[0]."\n").'0000';
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
test_assert_contains('ACK '.$history[0]." common\n", $output);
test_assert_contains('ACK '.$history[0]." ready\n", $output);
test_assert_contains('PACK', $output);
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack include-tag sends an annotated tag for a wanted commit', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
$tag = git_fixture_tag($fixture, $history[0]);
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
git_fixture_write_ref($fixture, 'refs/tags/v1.0.0', $tag);
$request = git_protocol_format_packet(
'want '.$history[0]." include-tag\n").'0000'.git_protocol_format_packet("done\n");
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
$nak = git_protocol_format_packet("NAK\n");
$pack = substr($output, strlen($nak));
$store = git_object_store_create($fixture);
$objects = git_receive_pack_parse($pack, $store, 0, 0);
test_assert_true(is_array($objects));
test_assert_true(isset($objects[$tag]), 'Expected include-tag to add the tag object.');
} finally {
test_remove_directory($fixture);
}
});
+30 -44
View File
@@ -1,49 +1,35 @@
<?php
require_once dirname(__DIR__).'/lib/repository.php';
require_once dirname(__DIR__).'/lib/git_service.php';
require_once __DIR__.'/bootstrap.php';
require_once __DIR__.'/git_fixture.php';
function test_remove_directory($path) {
if (!is_dir($path)) {
return;
test_case('unborn HEAD follows the first pushed branch', function () {
$fixture = git_fixture_create();
try {
$oid = git_fixture_history($fixture, array('one'))[0];
git_fixture_write_ref($fixture, 'refs/heads/master', $oid);
test_assert_true(git_service_update_unborn_head(
$fixture, array('refs/heads/master')));
test_assert_same(
"ref: refs/heads/master\n",
file_get_contents($fixture.'/HEAD'));
} finally {
test_remove_directory($fixture);
}
foreach (scandir($path) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$entry_path = $path.'/'.$entry;
if (is_dir($entry_path)) {
test_remove_directory($entry_path);
} else {
unlink($entry_path);
}
});
test_case('an established HEAD remains unchanged', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('main', 'master'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
git_fixture_write_ref($fixture, 'refs/heads/master', $history[1]);
test_assert_true(git_service_update_unborn_head(
$fixture, array('refs/heads/master')));
test_assert_same(
"ref: refs/heads/main\n",
file_get_contents($fixture.'/HEAD'));
} finally {
test_remove_directory($fixture);
}
rmdir($path);
}
$path = sys_get_temp_dir().'/php-git-server-head-'.bin2hex(random_bytes(8)).'.git';
mkdir($path.'/refs/heads', 0777, TRUE);
mkdir($path.'/objects', 0777, TRUE);
file_put_contents($path.'/HEAD', "ref: refs/heads/main\n");
file_put_contents($path.'/refs/heads/master', str_repeat('1', 40)."\n");
try {
git_service_update_unborn_head($path, array('refs/heads/master'));
$head = file_get_contents($path.'/HEAD');
if ($head !== "ref: refs/heads/master\n") {
fwrite(STDERR, 'Expected HEAD to follow the first pushed branch, got: '.$head);
exit(1);
}
file_put_contents($path.'/refs/heads/main', str_repeat('2', 40)."\n");
git_service_update_unborn_head($path, array('refs/heads/master'));
$head = file_get_contents($path.'/HEAD');
if ($head !== "ref: refs/heads/master\n") {
fwrite(STDERR, "Expected an existing HEAD target to remain unchanged.\n");
exit(1);
}
} finally {
test_remove_directory($path);
}
fwrite(STDOUT, "unborn HEAD regression test passed\n");
});
+17
View File
@@ -0,0 +1,17 @@
<?php
require_once __DIR__.'/bootstrap.php';
$test_files = array(
__DIR__.'/protocol_upload_pack.php',
__DIR__.'/protocol_receive_pack.php',
__DIR__.'/regression_unborn_head.php');
foreach ($test_files as $test_file) {
require $test_file;
}
$passed = $GLOBALS['test_results']['passed'];
$failed = $GLOBALS['test_results']['failed'];
fwrite(STDOUT, "\n".$passed.' passed, '.$failed." failed\n");
exit($failed === 0 ? 0 : 1);