From a41884f7b8ab5519bc4a6d538101d8b5a7f3cda1 Mon Sep 17 00:00:00 2001 From: hanyixuanten <105723997+hanyixuanten@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:31:41 +0800 Subject: [PATCH] feat: improve pure PHP Git protocol validation and negotiation --- AGENTS.md | 13 +- lib/git_object_store.php | 67 +++++- lib/git_receive_pack.php | 45 +++- lib/git_upload_pack.php | 40 +++- tests/bootstrap.php | 111 ++++++++++ tests/git_fixture.php | 120 ++++++++++ tests/protocol_receive_pack.php | 366 +++++++++++++++++++++++++++++++ tests/protocol_upload_pack.php | 146 ++++++++++++ tests/regression_unborn_head.php | 74 +++---- tests/run.php | 17 ++ 10 files changed, 928 insertions(+), 71 deletions(-) create mode 100644 tests/bootstrap.php create mode 100644 tests/git_fixture.php create mode 100644 tests/protocol_receive_pack.php create mode 100644 tests/protocol_upload_pack.php create mode 100644 tests/run.php diff --git a/AGENTS.md b/AGENTS.md index eb09a7d..89b88b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/lib/git_object_store.php b/lib/git_object_store.php index 371c62f..5a8fc3a 100644 --- a/lib/git_object_store.php +++ b/lib/git_object_store.php @@ -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; diff --git a/lib/git_receive_pack.php b/lib/git_receive_pack.php index 5cfa75a..d33c711 100644 --- a/lib/git_receive_pack.php +++ b/lib/git_receive_pack.php @@ -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, ''); diff --git a/lib/git_upload_pack.php b/lib/git_upload_pack.php index 511f037..53983ae 100644 --- a/lib/git_upload_pack.php +++ b/lib/git_upload_pack.php @@ -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'; diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..9bf135b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,111 @@ + 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; +} diff --git a/tests/git_fixture.php b/tests/git_fixture.php new file mode 100644 index 0000000..d375414 --- /dev/null +++ b/tests/git_fixture.php @@ -0,0 +1,120 @@ + '/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 '.$timestamp." +0000\n"; + $body .= 'committer Fixture '.$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 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; +} diff --git a/tests/protocol_receive_pack.php b/tests/protocol_receive_pack.php new file mode 100644 index 0000000..a59cbfa --- /dev/null +++ b/tests/protocol_receive_pack.php @@ -0,0 +1,366 @@ + 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 1700000000 +0000\n"; + $body .= "committer Fixture 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 1700000000 +0000\n"; + $body .= "committer Fixture 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 1700000000 +0000\n"; + $body .= "committer Fixture 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 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 1700000000 +0000\n"; + $body .= "committer Fixture 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 1700000000 +0000\n"; + $body .= "committer Fixture 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 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); + } +}); diff --git a/tests/protocol_upload_pack.php b/tests/protocol_upload_pack.php new file mode 100644 index 0000000..62b2ede --- /dev/null +++ b/tests/protocol_upload_pack.php @@ -0,0 +1,146 @@ +