diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0535f..88b4581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,71 @@ All notable changes to this project are documented in this file. +## [v1.7.1] - Unreleased + +Changes since [v1.7.0](https://github.com/gitonomy/gitlib/releases/tag/v1.7.0). + +1.7.1 has the security fixes of 2.0 that break nothing of 1.6. Where 2.0 refuses every option, +1.7.1 only refuses what reads or writes a file: the other options of `git log` as revisions stay +deprecated, and work. The checks of a URL keep returning `false`, and clones keep throwing a +`RuntimeException`, where 2.0 throws an `InvalidArgumentException`. + +### Security + +- Refuse `--output` as a revision, which made git write a file: `getRevision('--output=/path')` + and everything reading a revision through it, `getLog()`, `getDiff()`, `getMergeBase()` or + `Branch::moveTo()` for instance. +- Refuse the options of `git blame` reading or writing a file as a revision: + `getBlame('--contents=/path', 'README.md')` returned the lines of any file, and the error of + `--ignore-revs-file` or `-S` printed its first line. +- Refuse a URL starting with a dash, which git read as an option: + `Admin::isValidRepository('--upload-pack=...')` ran the command in a repository with a remote, + `Admin::isValidRepositoryAndBranch()` and the clones when the second argument was a + repository. A path may start with a dash: `init()` and the clones end the options of git with + `--`, as `cloneFrom($url, '--upload-pack=...')` ran the command too. +- Refuse a hash starting with a dash in `ReferenceBag::createBranch()` and `createTag()`: + `createBranch('foo', '-d')` deleted the branch. So do the branch and the start point of + `WorkingCopy::checkoutNewBranch()`. +- Refuse an `fd::` URL, which makes git talk through a file descriptor it inherits from PHP, like + the socket of a database, and wait on it. +- Refuse an ssh URL whose host or user starts with a dash, even encoded (`%2D`), like + `ssh://-oProxyCommand=.../repository`: git before 2.14.1 passes it to ssh, which reads it as an + option and runs the command (CVE-2017-1000117). A `git://` URL whose host starts with a dash + is refused too: git passes it to the command of `core.gitProxy`. +- Hide the credentials of URLs (`https://user:token@host`), a password holding a `#` or a `?` + included, in the messages of `ProcessException` and of failed clones, in the logs, and in the + messages of the exceptions of Symfony Process, a timeout for instance, which keep their class. + From PHP 8.2, the traces hide them too (`#[\SensitiveParameter]`). +- Refuse a hook name leading out of the hooks directory: `Hooks::set('../../file', ...)`, + `remove('../config')` and `get('../config')` wrote, deleted and read files of the repository. + The names staying in the directory, like `post-receive.d/notify`, still work. +- Fix `Hooks` writing through a symbolic link to a missing file, out of the hooks directory: + `has()` returned `false` for it, and `set()` followed it. +- Ignore `core.fsmonitor`: git ran the command the configuration of a repository names there to + read the working tree, and even to diff a commit. +- Fix deleting a symbolic reference, which deleted the branch it points to instead: + `ReferenceBag::delete('refs/remotes/origin/HEAD')` deleted `origin/main`. +- Fix `ReferenceBag::delete('HEAD')` deleting a detached `HEAD`, which left no repository: it + throws an `InvalidArgumentException`. +- Require `symfony/process` 5.4.51, 6.4.33, 7.3.11, 7.4.5 or 8.0.5 and later: the older versions + escape the arguments wrongly on Windows (CVE-2024-51736, CVE-2026-24739). + +### Not fixed + +These fixes of 2.0 would break code written for 1.6. An application giving gitlib values it can't +trust has to check them itself: + +- `ext::` URLs stay accepted, as an application may use this transport on purpose. git refuses + them by default since 2.12: before that, `ext::sh -c ...` runs the command. +- The replacements of objects (`git replace`, `refs/replace/`) stay read, as `git replace --graft` + relies on them: whoever can push such a reference changes the message of a commit, or the + content of a blob, gitlib reads for a hash. Set `GIT_NO_REPLACE_OBJECTS` to `1` in the + `environmentVariables` of `CliDriver` to ignore them. +- `WorkingCopy::checkout()` still takes a path or an option: `checkout('README.md')` discards the + changes of the file, and `checkout('--force')` all of them. +- Moving a symbolic reference, with `Branch::moveTo()` or `ReferenceBag::update()`, still moves + the branch it points to. + ## [v1.7.0] - 2026-10-15 Changes since [v1.6.0](https://github.com/gitonomy/gitlib/releases/tag/v1.6.0). @@ -100,5 +165,6 @@ _Earlier versions are not documented here. See the [GitHub releases](https://github.com/gitonomy/gitlib/releases) and [commit history](https://github.com/gitonomy/gitlib/commits/v1.6.0) for prior changes._ +[v1.7.1]: https://github.com/gitonomy/gitlib/compare/v1.7.0...v1.7.1 [v1.7.0]: https://github.com/gitonomy/gitlib/compare/v1.6.0...v1.7.0 [v1.6.0]: https://github.com/gitonomy/gitlib/releases/tag/v1.6.0 diff --git a/composer.json b/composer.json index 4b9a2fc..076b075 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "ext-pcre": "*", "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/polyfill-mbstring": "^1.7", - "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" + "symfony/process": "^5.4.51 || ^6.4.33 || ~7.3.11 || ^7.4.5 || ^8.0.5" }, "require-dev": { "ext-fileinfo": "*", diff --git a/doc/repository.md b/doc/repository.md index 626a8af..2ac7214 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -49,6 +49,24 @@ option is the second argument, and its `logger`, `command`, `CliDriver`. Setting its `debug` option to `false` is deprecated: gitlib 2.0 always throws when git fails. +Ignore the replacements of objects +---------------------------------- + +git reads the replacements of a repository (`git replace`): whoever can +push one changes the message of a commit, or the content of a blob, you +read for a hash. For a repository you don't trust, ignore them: + +```php +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Repository; + +$repository = new Repository('/path/to/repo', driver: new CliDriver( + environmentVariables: ['GIT_NO_REPLACE_OBJECTS' => '1'], +)); +``` + +gitlib 2.0 always ignores them. + Test if a repository is bare ---------------------------- diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 9d56128..75f9295 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -14,7 +14,9 @@ use Gitonomy\Git\Driver\Cli\CliDriver; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Util\Credentials; use Gitonomy\Git\Util\LegacyOverride; +use Gitonomy\Git\Util\RemoteUrl; /** * Administration class for Git repositories. @@ -68,14 +70,18 @@ public static function init($path, $bare = true, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepository() instead */ - public static function isValidRepository($url, array $options = []) + public static function isValidRepository(#[\SensitiveParameter] $url, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepository()" instead.', __METHOD__, Repository::class); + if (null !== RemoteUrl::unsafeReason((string) $url)) { + return false; + } + // Unlike Repository::isValidRepository(), in the working directory of PHP, where a relative path may name a remote. $process = CliDriver::fromOptions($options)->createProcess('ls-remote', [$url]); - $process->run(); + Credentials::runProcess($process); return $process->isSuccessFul(); } @@ -96,15 +102,19 @@ public static function isValidRepository($url, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::isValidRepositoryAndBranch() instead */ - public static function isValidRepositoryAndBranch($url, $branchName, array $options = []) + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] $url, $branchName, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::isValidRepositoryAndBranch()" instead.', __METHOD__, Repository::class); + if (null !== RemoteUrl::unsafeReason((string) $url)) { + return false; + } + // Unlike Repository::isValidRepositoryAndBranch(), "release" matches "feature/release", and "rel" "release", // in the working directory of PHP. $process = CliDriver::fromOptions($options)->createProcess('ls-remote', ['--heads', $url, $branchName]); - $process->run(); + Credentials::runProcess($process); $processOutput = $process->getOutput(); return $process->isSuccessFul() && strpos($processOutput, $branchName) !== false; @@ -122,7 +132,7 @@ public static function isValidRepositoryAndBranch($url, $branchName, array $opti * * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() instead */ - public static function cloneTo($path, $url, $bare = true, array $options = []) + public static function cloneTo($path, #[\SensitiveParameter] $url, $bare = true, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -144,7 +154,7 @@ public static function cloneTo($path, $url, $bare = true, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneBareFrom() or Repository::cloneFrom() with their "branch" argument instead */ - public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = []) + public static function cloneBranchTo($path, #[\SensitiveParameter] $url, $branch, $bare = true, $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::%s()" with its "branch" argument instead, which takes the URL before the path.', __METHOD__, Repository::class, $bare ? 'cloneBareFrom' : 'cloneFrom'); @@ -167,7 +177,7 @@ public static function cloneBranchTo($path, $url, $branch, $bare = true, $option * * @deprecated since gitonomy/gitlib 1.7, use Repository::mirrorFrom() instead */ - public static function mirrorTo($path, $url, array $options = []) + public static function mirrorTo($path, #[\SensitiveParameter] $url, array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%s()" method is deprecated, use "%s::mirrorFrom()" instead, which takes the URL before the path.', __METHOD__, Repository::class); @@ -186,7 +196,7 @@ public static function mirrorTo($path, $url, array $options = []) * * @deprecated since gitonomy/gitlib 1.7, use Repository::cloneFrom(), Repository::cloneBareFrom() or Repository::mirrorFrom() instead */ - public static function cloneRepository($path, $url, array $args = [], array $options = []) + public static function cloneRepository($path, #[\SensitiveParameter] $url, array $args = [], array $options = []) { trigger_deprecation('gitonomy/gitlib', '1.7', 'The "%1$s()" method is deprecated, use "%2$s::cloneFrom()", "%2$s::cloneBareFrom()" or "%2$s::mirrorFrom()" instead, which take the URL before the path; they support no other argument of "git clone".', __METHOD__, Repository::class); @@ -195,7 +205,7 @@ public static function cloneRepository($path, $url, array $args = [], array $opt return new Repository($path, $options); } - private static function cloneWith($path, $url, array $args, array $options) + private static function cloneWith($path, #[\SensitiveParameter] $url, array $args, array $options) { // Like in 1.6, a subclass overriding cloneRepository() clones the repository. if (LegacyOverride::isOverridden(static::class, self::class, 'cloneRepository')) { diff --git a/src/Gitonomy/Git/Blame.php b/src/Gitonomy/Git/Blame.php index e9d3e40..7337082 100644 --- a/src/Gitonomy/Git/Blame.php +++ b/src/Gitonomy/Git/Blame.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Blame\Line; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Parser\BlameParser; +use Gitonomy\Git\Util\LeadingDash; /** * @author Alexandre Salomé @@ -167,6 +168,8 @@ public function getLines() return $this->lines; } + LeadingDash::assertNoBlameFileOption($this->revision->getRevision()); + $args = ['-p']; if (null !== $this->lineRange) { diff --git a/src/Gitonomy/Git/Driver/Cli/CliDriver.php b/src/Gitonomy/Git/Driver/Cli/CliDriver.php index c999bd2..4d43463 100644 --- a/src/Gitonomy/Git/Driver/Cli/CliDriver.php +++ b/src/Gitonomy/Git/Driver/Cli/CliDriver.php @@ -13,6 +13,8 @@ namespace Gitonomy\Git\Driver\Cli; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -77,7 +79,8 @@ public function getOptions(): array */ public function init(string $path): void { - $this->initRepository(['-q', $path]); + // --: a path may start with a dash. + $this->initRepository(['-q', '--', $path]); } /** @@ -87,7 +90,7 @@ public function init(string $path): void */ public function initBare(string $path): void { - $this->initRepository(['-q', '--bare', $path]); + $this->initRepository(['-q', '--bare', '--', $path]); } /** @@ -95,7 +98,7 @@ public function initBare(string $path): void * * @throws RuntimeException */ - public function cloneFrom(string $url, string $path, ?string $branch): void + public function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($url, $path, null === $branch ? [] : ['--branch', $branch]); } @@ -105,7 +108,7 @@ public function cloneFrom(string $url, string $path, ?string $branch): void * * @throws RuntimeException */ - public function cloneBareFrom(string $url, string $path, ?string $branch): void + public function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch): void { $this->cloneRepository($url, $path, null === $branch ? ['--bare'] : ['--branch', $branch, '--bare']); } @@ -115,7 +118,7 @@ public function cloneBareFrom(string $url, string $path, ?string $branch): void * * @throws RuntimeException */ - public function mirrorFrom(string $url, string $path): void + public function mirrorFrom(#[\SensitiveParameter] string $url, string $path): void { $this->cloneRepository($url, $path, ['--mirror']); } @@ -123,10 +126,14 @@ public function mirrorFrom(string $url, string $path): void /** * @internal For Repository::isValidRepository() */ - public function isValidRepository(string $url): bool + public function isValidRepository(#[\SensitiveParameter] string $url): bool { + if (null !== RemoteUrl::unsafeReason($url)) { + return false; + } + $process = $this->createLsRemoteProcess([], $url, []); - $process->run(); + Credentials::runProcess($process); return $process->isSuccessful(); } @@ -134,11 +141,15 @@ public function isValidRepository(string $url): bool /** * @internal For Repository::isValidRepositoryAndBranch() */ - public function isValidRepositoryAndBranch(string $url, string $branch): bool + public function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch): bool { + if (null !== RemoteUrl::unsafeReason($url)) { + return false; + } + // git lists the branches whose name ends with the pattern: "release" matches "feature/release". $process = $this->createLsRemoteProcess(['--heads'], $url, ['refs/heads/'.$branch]); - $process->run(); + Credentials::runProcess($process); if (!$process->isSuccessful()) { return false; } @@ -159,13 +170,19 @@ public function isValidRepositoryAndBranch(string $url, string $branch): bool * * @throws RuntimeException */ - public function cloneRepository(string $url, string $path, array $args): void + public function cloneRepository(#[\SensitiveParameter] string $url, string $path, array $args): void { - $process = $this->createProcess('clone', array_merge(['-q'], $args, [$url, $path])); - $process->run(); + $reason = RemoteUrl::unsafeReason($url); + if (null !== $reason) { + throw new RuntimeException(sprintf('Error while initializing repository: %s', $reason)); + } + + // --: a path may start with a dash. + $process = $this->createProcess('clone', array_merge(['-q'], $args, ['--', $url, $path])); + Credentials::runProcess($process); if (!$process->isSuccessful()) { - throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput())); + throw new RuntimeException(sprintf('Error while initializing repository: %s', Credentials::redact($process->getErrorOutput()))); } } diff --git a/src/Gitonomy/Git/Exception/ProcessException.php b/src/Gitonomy/Git/Exception/ProcessException.php index c4e2002..ead27c0 100644 --- a/src/Gitonomy/Git/Exception/ProcessException.php +++ b/src/Gitonomy/Git/Exception/ProcessException.php @@ -2,6 +2,7 @@ namespace Gitonomy\Git\Exception; +use Gitonomy\Git\Util\Credentials; use Symfony\Component\Process\Process; /** @@ -11,16 +12,20 @@ class ProcessException extends RuntimeException implements GitExceptionInterface { protected $process; + /** + * The message hides the credentials of the URLs, unlike getErrorOutput() + * and getOutput(). + */ public function __construct(Process $process) { - parent::__construct( + parent::__construct(Credentials::redact( "Error while running git command:\n". $process->getCommandLine()."\n". "\n". $process->getErrorOutput()."\n". "\n". $process->getOutput() - ); + )); $this->process = $process; } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index d5fdd0a..ecb9d34 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -43,11 +43,17 @@ public function __construct(Repository $repository) * * @param string $name Name of the hook * + * @throws InvalidArgumentException The name leads out of the hooks directory + * * @return bool */ public function has($name) { - return file_exists($this->getPath($name)); + $path = $this->getPath($name); + + // A symbolic link to a file not deployed yet is a hook too: set() + // would write through it, out of the hooks directory. + return is_link($path) || file_exists($path); } /** @@ -55,13 +61,13 @@ public function has($name) * * @param string $name Name of the hook * - * @throws InvalidArgumentException Hook does not exist + * @throws InvalidArgumentException Hook does not exist, or the name leads out of the hooks directory * * @return string Content of the hook */ public function get($name) { - if (!$this->has($name)) { + if (!$this->has($name) || !file_exists($this->getPath($name))) { throw new InvalidArgumentException(sprintf('Hook named "%s" is not present', $name)); } @@ -74,8 +80,9 @@ public function get($name) * @param string $name Name of the hook to insert * @param string $file Target of symlink * - * @throws LogicException Hook is already present - * @throws RuntimeException Error on symlink creation + * @throws InvalidArgumentException The name leads out of the hooks directory + * @throws LogicException Hook is already present + * @throws RuntimeException Error on symlink creation */ public function setSymlink($name, $file) { @@ -95,7 +102,8 @@ public function setSymlink($name, $file) * @param string $name Name of the hook * @param string $content Content of the hook * - * @throws LogicException The hook is already defined + * @throws InvalidArgumentException The name leads out of the hooks directory + * @throws LogicException The hook is already defined */ public function set($name, $content) { @@ -113,7 +121,8 @@ public function set($name, $content) * * @param string $name Name of the hook * - * @throws LogicException The hook is not present + * @throws InvalidArgumentException The name leads out of the hooks directory + * @throws LogicException The hook is not present */ public function remove($name) { @@ -125,10 +134,19 @@ public function remove($name) } /** + * Unlike 2.0, 1.x accepts a name in a subdirectory, like "post-receive.d/notify", + * or starting with a dot. + * + * @throws InvalidArgumentException The name leads out of the hooks directory + * * @return string */ protected function getPath($name) { + if (1 === preg_match('#(?:^|[/\\\\])\.\.(?:[/\\\\]|$)#', (string) $name)) { + throw new InvalidArgumentException(sprintf('The name of the hook "%s" leads out of the hooks directory.', $name)); + } + return $this->repository->getGitDir().'/hooks/'.$name; } } diff --git a/src/Gitonomy/Git/Log.php b/src/Gitonomy/Git/Log.php index fae1636..cd1c16f 100644 --- a/src/Gitonomy/Git/Log.php +++ b/src/Gitonomy/Git/Log.php @@ -16,6 +16,7 @@ use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\LegacyOverride; use Gitonomy\Git\Util\StringHelper; @@ -59,6 +60,8 @@ class Log implements \Countable, \IteratorAggregate * @param array $paths paths to filter on * @param int|null $offset start list from a given position * @param int|null $limit limit number of fetched elements + * + * @throws InvalidArgumentException A revision is an option making git write a file */ public function __construct(Repository $repository, $revisions = null, $paths = null, $offset = null, $limit = null) { @@ -67,11 +70,13 @@ public function __construct(Repository $repository, $revisions = null, $paths = } foreach (null === $revisions ? [] : $revisions->getAll() as $revision) { + $name = $revision->getRevision(); + LeadingDash::assertNoLogFileOption($name); + if ($revision instanceof Commit || $revision instanceof Reference) { continue; } - $name = $revision->getRevision(); if (str_starts_with($name, '-') && '--all' !== $name) { trigger_deprecation('gitonomy/gitlib', '1.7', 'Passing "%s", an option of "git log", as revision to "%s()" is deprecated, it throws in 2.0: pass revisions, ranges, names to exclude or "--all".', $name, __METHOD__); } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index 2358a46..113fe84 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -12,11 +12,13 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Reference\Stash; use Gitonomy\Git\Reference\Tag; +use Gitonomy\Git\Util\LeadingDash; use Gitonomy\Git\Util\LegacyOverride; /** @@ -158,15 +160,28 @@ public function createTag($name, $commitHash) /** * Deleting a reference by another name than its full name under "refs/", like "HEAD" or "master", is deprecated. * + * @throws InvalidArgumentException HEAD is detached: deleting it would leave no repository + * * @return void */ public function delete($fullname) { + // --no-deref: a symbolic reference, like refs/remotes/origin/HEAD, is + // deleted, not the branch it points to. + $args = ['--no-deref', '-d', $fullname]; + if (!str_starts_with($fullname, 'refs/')) { trigger_deprecation('gitonomy/gitlib', '1.7', 'Deleting "%s", which is not the full name of a reference under "refs/", is deprecated, it throws in 2.0.', $fullname); + + if ('HEAD' === $fullname && $this->repository->isHeadDetached()) { + throw new InvalidArgumentException('A detached HEAD can\'t be deleted, which would leave no repository.'); + } + + // Like in 1.6: deleting an attached HEAD deletes its branch. + $args = ['-d', $fullname]; } - $this->repository->run('update-ref', ['-d', $fullname]); + $this->repository->run('update-ref', $args); unset($this->references[$fullname]); } @@ -447,8 +462,12 @@ private function updateReference(Reference $reference) { $fullname = $reference->getFullname(); + $hash = $reference->getCommitHash(); + // "git update-ref refs/heads/foo -d" deletes the branch. + LeadingDash::assertNone($hash); + $this->initialize(); - $this->repository->run('update-ref', [$fullname, $reference->getCommitHash()]); + $this->repository->run('update-ref', [$fullname, $hash]); $this->references[$fullname] = $reference; diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index fc78d09..cb358f9 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -18,6 +18,8 @@ use Gitonomy\Git\Exception\ProcessException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\LeadingDash; use Psr\Log\LoggerInterface; use Symfony\Component\Process\Process; @@ -217,7 +219,7 @@ public static function initBare(string $path, ?CliDriver $driver = null): self * * @throws RuntimeException */ - public static function cloneFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + public static function cloneFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self { $driver = $driver ?? new CliDriver(); $driver->cloneFrom($url, $path, $branch); @@ -233,7 +235,7 @@ public static function cloneFrom(string $url, string $path, ?string $branch = nu * * @throws RuntimeException */ - public static function cloneBareFrom(string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self + public static function cloneBareFrom(#[\SensitiveParameter] string $url, string $path, ?string $branch = null, ?CliDriver $driver = null): self { $driver = $driver ?? new CliDriver(); $driver->cloneBareFrom($url, $path, $branch); @@ -249,7 +251,7 @@ public static function cloneBareFrom(string $url, string $path, ?string $branch * * @throws RuntimeException */ - public static function mirrorFrom(string $url, string $path, ?CliDriver $driver = null): self + public static function mirrorFrom(#[\SensitiveParameter] string $url, string $path, ?CliDriver $driver = null): self { $driver = $driver ?? new CliDriver(); $driver->mirrorFrom($url, $path); @@ -263,7 +265,7 @@ public static function mirrorFrom(string $url, string $path, ?CliDriver $driver * * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null */ - public static function isValidRepository(string $url, ?CliDriver $driver = null): bool + public static function isValidRepository(#[\SensitiveParameter] string $url, ?CliDriver $driver = null): bool { return ($driver ?? new CliDriver())->isValidRepository($url); } @@ -274,7 +276,7 @@ public static function isValidRepository(string $url, ?CliDriver $driver = null) * * @param CliDriver|null $driver Configures git, a CliDriver with its default arguments when null */ - public static function isValidRepositoryAndBranch(string $url, string $branch, ?CliDriver $driver = null): bool + public static function isValidRepositoryAndBranch(#[\SensitiveParameter] string $url, string $branch, ?CliDriver $driver = null): bool { return ($driver ?? new CliDriver())->isValidRepositoryAndBranch($url, $branch); } @@ -637,6 +639,8 @@ public function getLegacyDiff($revisions): Diff $revisions = new RevisionList($this, $revisions); } + LeadingDash::assertNoLogFileOption(...$revisions->getAsTextArray()); + return $this->runDiff($revisions->getAsTextArray()); } @@ -768,7 +772,7 @@ public function setDescription($description) * * @return string Output of a successful process or null if execution failed and debug-mode is disabled. */ - public function run($command, $args = []) + public function run($command, #[\SensitiveParameter] $args = []) { $process = $this->execute($command, $args); @@ -853,22 +857,22 @@ private function commitHash(Revision|string $revision): string /** * Runs a git command, and logs it. */ - private function execute($command, $args = []): Process + private function execute($command, #[\SensitiveParameter] $args = []): Process { $process = $this->getProcess($command, $args); if ($this->logger) { - $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args))); + $this->logger->info(Credentials::redact(sprintf('run command: %s "%s" ', $command, implode(' ', $args)))); $before = microtime(true); } - $process->run(); + Credentials::runProcess($process); if ($this->logger && $this->debug) { $duration = microtime(true) - $before; $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000))); $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode())); - $this->logger->debug(sprintf('last command (%s) output: %s', $command, $process->getOutput())); + $this->logger->debug(Credentials::redact(sprintf('last command (%s) output: %s', $command, $process->getOutput()))); } return $process; @@ -882,7 +886,7 @@ private function execute($command, $args = []): Process private function fail($command, Process $process): void { if ($this->logger) { - $this->logger->error(sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput())); + $this->logger->error(Credentials::redact(sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput()))); } if ($this->debug) { @@ -910,11 +914,16 @@ private function getProcess($command, $args = []) $process = new Process(array_merge($base, $args)); - if ($this->inheritEnvironmentVariables) { - $process->setEnv(array_replace($_SERVER, $this->environmentVariables)); - } else { - $process->setEnv($this->environmentVariables); - } + $environmentVariables = $this->inheritEnvironmentVariables ? array_replace($_SERVER, $this->environmentVariables) : $this->environmentVariables; + + // git runs the command core.fsmonitor names, which the configuration of a + // repository may set, to read the working tree, and even to diff a commit. + // What "git -c" sets, in its old format, the only one git before 2.31 reads: + // git before 1.7.2 has no -c. After the values of git's parent, to win over them. + $configParameters = (string) ($environmentVariables['GIT_CONFIG_PARAMETERS'] ?? getenv('GIT_CONFIG_PARAMETERS')); + $environmentVariables['GIT_CONFIG_PARAMETERS'] = ltrim($configParameters." 'core.fsmonitor=false'"); + + $process->setEnv($environmentVariables); $process->setTimeout($this->processTimeout); $process->setIdleTimeout($this->processTimeout); diff --git a/src/Gitonomy/Git/Util/Credentials.php b/src/Gitonomy/Git/Util/Credentials.php new file mode 100644 index 0000000..7863f99 --- /dev/null +++ b/src/Gitonomy/Git/Util/Credentials.php @@ -0,0 +1,54 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Symfony\Component\Process\Exception\ExceptionInterface; +use Symfony\Component\Process\Process; + +/** + * A URL like "https://user:token@host/repository.git" carries credentials: + * the messages of exceptions and of the logger hide them. + * + * @author Grégoire Pineau + * + * @internal + */ +final class Credentials +{ + /** + * Replaces the user information of the URLs of $text, a token or a user + * and a password, by "***". + */ + public static function redact(#[\SensitiveParameter] string $text): string + { + // Up to the last "@" before the host, as a password may hold one, or a + // "#" or a "?", which git accepts unencoded there. + return preg_replace('#\b([a-z][a-z0-9+.-]*+://)(?:[^/\s@]*+@)++#i', '$1***@', $text); + } + + /** + * Runs $process. The messages of the exceptions of Symfony Process hold + * the command line, with its URLs: unlike in 2.0, they keep their class, + * which code written for 1.6 catches. + */ + public static function runProcess(Process $process): void + { + try { + $process->run(); + } catch (ExceptionInterface $e) { + (new \ReflectionProperty(\Exception::class, 'message'))->setValue($e, self::redact($e->getMessage())); + + throw $e; + } + } +} diff --git a/src/Gitonomy/Git/Util/LeadingDash.php b/src/Gitonomy/Git/Util/LeadingDash.php new file mode 100644 index 0000000..fe24d3f --- /dev/null +++ b/src/Gitonomy/Git/Util/LeadingDash.php @@ -0,0 +1,83 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +use Gitonomy\Git\Exception\InvalidArgumentException; + +/** + * git reads an argument starting with a dash as an option: the revision + * "--output=/path" of a log writes a file. git before 2.24 has no + * "--end-of-options", and what follows "--" is a path for most commands. + * + * 2.0 refuses them all. 1.x refuses them all only where no option can be + * used: the options of "git log" as revisions are deprecated, not removed, so + * logs and blames keep them, but the ones reading or writing a file. + * + * @author Grégoire Pineau + * + * @internal + */ +final class LeadingDash +{ + /** + * Not the abbreviations of --output, which "git log" doesn't accept. + * --output-indicator-new and the like write no file. + */ + private const LOG_FILE_OPTION = '/^--output(?:=|$)/'; + + /** + * "git blame" takes the options of "git log", and reads files: it returns + * the lines of --contents, and prints the first one of --ignore-revs-file + * or -S in its error. It accepts abbreviations, of any length that isn't + * ambiguous for the git in use, and a short option in a group, like "-wS". + */ + private const BLAME_FILE_OPTION = '/^(?:--output(?:=|$)|--c(?:o|on|ont|onte|onten|ontent|ontents)?(?:=|$)|--ignore-revs|-[^-]*S)/'; + + /** + * @throws InvalidArgumentException One of the values starts with a dash + */ + public static function assertNone(string ...$values): void + { + foreach ($values as $value) { + if (str_starts_with($value, '-')) { + throw new InvalidArgumentException(sprintf('"%s" starts with a dash, which git would read as an option.', $value)); + } + } + } + + /** + * @param mixed ...$revisions The revisions of a log or a diff + * + * @throws InvalidArgumentException One of the revisions is an option making git write a file + */ + public static function assertNoLogFileOption(...$revisions): void + { + foreach ($revisions as $revision) { + if (1 === preg_match(self::LOG_FILE_OPTION, (string) $revision)) { + throw new InvalidArgumentException(sprintf('"%s" is an option of git writing a file, not a revision.', $revision)); + } + } + } + + /** + * @param mixed $revision The revision of a blame + * + * @throws InvalidArgumentException The revision is an option making git read or write a file + */ + public static function assertNoBlameFileOption($revision): void + { + if (1 === preg_match(self::BLAME_FILE_OPTION, (string) $revision)) { + throw new InvalidArgumentException(sprintf('"%s" is an option of git reading or writing a file, not a revision.', $revision)); + } + } +} diff --git a/src/Gitonomy/Git/Util/RemoteUrl.php b/src/Gitonomy/Git/Util/RemoteUrl.php new file mode 100644 index 0000000..01f0d4e --- /dev/null +++ b/src/Gitonomy/Git/Util/RemoteUrl.php @@ -0,0 +1,78 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Util; + +/** + * Tells the URLs of a repository that make git run a command, or use a file + * descriptor of PHP, as an application may get them from its users. + * + * Unlike 2.0, 1.x accepts "ext::" URLs: an application may use this transport + * on purpose, through git's protocol.ext.allow setting or GIT_ALLOW_PROTOCOL. + * + * @author Grégoire Pineau + * + * @internal + */ +final class RemoteUrl +{ + /** + * @return string|null Why git must not get $url, null when it can + */ + public static function unsafeReason(#[\SensitiveParameter] string $url): ?string + { + // Before the URL, git would read it as an option: "--upload-pack=..." + // runs the command. ls-remote only takes "--" since git 2.8. + if (str_starts_with($url, '-')) { + return sprintf('"%s" starts with a dash, which git would read as an option.', Credentials::redact($url)); + } + + // "fd::7" talks through the file descriptors git inherits from PHP: + // an open file, the socket of a database... git waits on it until it + // times out, an hour by default. + if (str_starts_with($url, 'fd::')) { + return sprintf('"%s" uses the "fd::" transport, which talks through a file descriptor of PHP.', Credentials::redact($url)); + } + + // Before 2.14.1 (CVE-2017-1000117), git passes the "user@host" of an ssh + // URL to ssh, without its brackets: ssh reads "ssh://-oProxyCommand=..." + // as an option, and runs the command. The command of core.gitProxy gets + // the host of a git URL the same way. A valid host never starts with a dash. + $authority = self::authority($url); + if (null !== $authority && 1 === preg_match('/(?:^|@)\[?-/', $authority)) { + return sprintf('"%s" has a host or a user starting with a dash, which ssh, or the command of core.gitProxy, would read as an option.', Credentials::redact($url)); + } + + return null; + } + + /** + * @return string|null The "user@host:port" of an ssh or git URL, decoded, + * or of an address like "user@host:path", which git + * reaches through ssh; null for any other URL, or a + * local path + */ + private static function authority(#[\SensitiveParameter] string $url): ?string + { + // git decodes a URL before it reads its host: "%2D" is a dash, "%5B" a bracket. + if (1 === preg_match('#^(?:git|ssh|git\+ssh|ssh\+git)://([^/]*)#', $url, $matches)) { + return rawurldecode($matches[1]); + } + + // Like git: a colon before any slash, but not the one of "scheme://". + if (1 === preg_match('#^([^/:]*):(?!//)#', $url, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index faf209e..94ea247 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -17,6 +17,7 @@ use Gitonomy\Git\Exception\LogicException; use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Util\LeadingDash; /** * @author Alexandre Salomé @@ -116,14 +117,18 @@ public function checkout($revision, $branch = null) * * @return $this * + * @throws InvalidArgumentException $branch or $startPoint starts with a dash * @throws ReferenceNotFoundException $startPoint names no commit */ public function checkoutNewBranch(string $branch, Revision|string $startPoint = 'HEAD'): self { + $startPointName = $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint; + LeadingDash::assertNone($branch, $startPointName); + // Like in 2.0. git gets the name, which tells a remote branch, to track it. ($startPoint instanceof Revision ? $startPoint : $this->repository->getRevision($startPoint))->getCommit(); - $this->run('checkout', ['-b', $branch, $startPoint instanceof Revision ? $startPoint->getRevision() : $startPoint]); + $this->run('checkout', ['-b', $branch, $startPointName]); return $this; } diff --git a/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php index 6c4434b..d630a97 100644 --- a/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php +++ b/tests/Gitonomy/Git/Tests/BackwardCompatibilityTest.php @@ -14,9 +14,11 @@ use Gitonomy\Git\Admin; use Gitonomy\Git\Blame; +use Gitonomy\Git\Commit; use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Diff\File; use Gitonomy\Git\Diff\FileChange; +use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Log; use Gitonomy\Git\PushReference; use Gitonomy\Git\Reference; @@ -29,6 +31,114 @@ */ class BackwardCompatibilityTest extends AbstractTest { + /** + * The options of "git log" as revisions are deprecated, not removed: 1.x + * only refuses --output, which writes a file. + * + * @group legacy + */ + public function testLogsKeepTheOptionsOfGit() + { + $repository = self::createFoobarRepository(); + + $this->assertNotCount(0, $repository->getLog('--all')->getCommits()); + $this->assertNotCount(0, $repository->getLog(['--no-merges', 'master'])->getCommits()); + $this->assertNotCount(0, $repository->getLog(['--since=1970-01-01', 'master'])->getCommits()); + $this->assertSame(self::LONGFILE_COMMIT, $repository->getLog([self::LONGFILE_COMMIT, '--max-count=1'])->getSingleCommit()->getHash()); + $this->assertInstanceOf(Commit::class, $repository->getRevision('--all')->getCommit()); + } + + /** + * @group legacy + */ + public function testDiffsKeepTheOptionsOfGit() + { + $repository = self::createFoobarRepository(); + + $this->assertCount(1, $repository->getDiff(['--no-renames', self::BEFORE_LONGFILE_COMMIT, self::LONGFILE_COMMIT])->getFiles()); + } + + public function testBlamesKeepTheOptionsOfGitReadingNoFile() + { + $repository = self::createFoobarRepository(false); + + $this->assertNotCount(0, $repository->getBlame('-w', 'README.md')); + } + + public function testCheckoutKeepsThePreviousBranch() + { + $repository = self::createFoobarRepository(false); + $repository->run('checkout', ['-q', '-b', 'foo']); + $repository->run('checkout', ['-q', 'master']); + + $repository->getWorkingCopy()->checkout('-'); + + $this->assertSame("foo\n", $repository->run('rev-parse', ['--abbrev-ref', 'HEAD'])); + } + + public function testHooksKeepTheNamesThatStayInTheHooksDirectory() + { + $repository = self::createEmptyRepository(); + mkdir($repository->getGitDir().'/hooks/post-receive.d'); + + $hooks = $repository->getHooks(); + $hooks->set('post-receive.d/notify', 'content'); + $hooks->set('.common', 'content'); + $hooks->set('foo..bar', 'content'); + + $this->assertSame('content', $hooks->get('post-receive.d/notify')); + $this->assertSame('content', $hooks->get('.common')); + $this->assertSame('content', $hooks->get('foo..bar')); + } + + /** + * A failing check is false, and a failing clone a RuntimeException, like + * in 1.6: code validating a URL handles nothing else. + * + * @group legacy + */ + public function testRefusedUrlsKeepTheContractsOf16() + { + $this->assertFalse(Admin::isValidRepository('--upload-pack=foo', self::getOptions())); + $this->assertFalse(Admin::isValidRepositoryAndBranch('--upload-pack=foo', 'master', self::getOptions())); + $this->assertFalse(Admin::isValidRepository(null, self::getOptions())); + + $this->expectException(RuntimeException::class); + + Admin::cloneTo(self::createTempDir().'/clone', '--upload-pack=foo', true, self::getOptions()); + } + + /** + * The transport an application allows through git's protocol.ext.allow + * setting, or GIT_ALLOW_PROTOCOL. + * + * @group legacy + */ + public function testAnExtUrlIsUpToGit() + { + $bare = self::createFoobarRepository()->getGitDir(); + $options = self::getOptions(); + $options['environment_variables']['GIT_ALLOW_PROTOCOL'] = 'ext:file'; + + $this->assertTrue(Admin::isValidRepository('ext::git-upload-pack '.$bare, $options)); + } + + /** + * Like in 1.6, deleting an attached HEAD deletes its branch. + * + * @group legacy + */ + public function testDeletingAnAttachedHeadStillDeletesItsBranch() + { + $repository = self::createFoobarRepository(false); + $repository->run('checkout', ['-q', '-b', 'foo']); + + $repository->getReferences()->delete('HEAD'); + + $this->assertStringNotContainsString('refs/heads/foo', $repository->run('show-ref')); + $this->assertFileExists($repository->getGitDir().'/HEAD'); + } + public function testEmptyOptions() { $source = self::createFoobarRepository(false); diff --git a/tests/Gitonomy/Git/Tests/SecurityTest.php b/tests/Gitonomy/Git/Tests/SecurityTest.php new file mode 100644 index 0000000..66150c9 --- /dev/null +++ b/tests/Gitonomy/Git/Tests/SecurityTest.php @@ -0,0 +1,794 @@ + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Gitonomy\Git\Tests; + +use Gitonomy\Git\Admin; +use Gitonomy\Git\Driver\Cli\CliDriver; +use Gitonomy\Git\Exception\InvalidArgumentException; +use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\ProcessException; +use Gitonomy\Git\Exception\RuntimeException; +use Gitonomy\Git\Reference\Branch; +use Gitonomy\Git\Repository; +use Gitonomy\Git\Util\Credentials; +use Gitonomy\Git\Util\RemoteUrl; +use Psr\Log\AbstractLogger; +use Symfony\Component\Process\Exception\ExceptionInterface; +use Symfony\Component\Process\Exception\ProcessTimedOutException; + +/** + * An application may get a revision, a URL or the name of a hook from its + * users: git must not read them as options, and run nothing for them. + */ +class SecurityTest extends AbstractTest +{ + /** + * Nothing listens on port 1: the commands fail. + */ + private const URL = 'http://user:secret@127.0.0.1:1/repository.git'; + + /** + * @group legacy + * + * @dataProvider provideOutputCalls + */ + public function testAnOptionWritingAFileIsRefused(\Closure $call) + { + $repository = self::createFoobarRepository(false); + $file = self::createTempDir().'/written'; + + try { + $call($repository, '--output='.$file); + $this->fail('--output is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('writing a file', $e->getMessage()); + } + + $this->assertFileIsAbsent($file); + } + + public static function provideOutputCalls() + { + yield 'Revision::getCommit()' => [function (Repository $repository, string $option) { + $repository->getRevision($option)->getCommit(); + }]; + yield 'Revision::getLog()' => [function (Repository $repository, string $option) { + $repository->getRevision($option)->getLog()->getCommits(); + }]; + yield 'Repository::getLog()' => [function (Repository $repository, string $option) { + $repository->getLog($option)->getCommits(); + }]; + yield 'Repository::getLog(), among revisions' => [function (Repository $repository, string $option) { + $repository->getLog(['master', $option])->getCommits(); + }]; + yield 'Repository::getDiff(), one argument' => [function (Repository $repository, string $option) { + $repository->getDiff($option); + }]; + yield 'Repository::getDiff(), two arguments' => [function (Repository $repository, string $option) { + $repository->getDiff($option, 'master'); + }]; + yield 'Repository::getMergeBase()' => [function (Repository $repository, string $option) { + $repository->getMergeBase($option, 'master'); + }]; + yield 'WorkingCopy::getDiff()' => [function (Repository $repository, string $option) { + $repository->getWorkingCopy()->getDiff($option); + }]; + yield 'Branch::moveTo()' => [function (Repository $repository, string $option) { + $repository->getReferences()->getBranch('master')->moveTo($option); + }]; + yield 'Branch::isMergedInto()' => [function (Repository $repository, string $option) { + $repository->getReferences()->getBranch('master')->isMergedInto($option); + }]; + yield 'ReferenceBag::createBranch()' => [function (Repository $repository, string $option) { + $repository->getReferences()->createBranch('foo', $repository->getRevision($option)); + }]; + } + + /** + * git takes the file as the next argument too. + */ + public function testAnOptionWritingAFileIsRefusedInTwoArguments() + { + $repository = self::createFoobarRepository(); + $file = self::createTempDir().'/written'; + + try { + $repository->getLog(['--output', $file])->getCommits(); + $this->fail('--output is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('writing a file', $e->getMessage()); + } + + $this->assertFileIsAbsent($file); + } + + /** + * @dataProvider provideBlameFileOptions + */ + public function testBlameRefusesTheOptionsReadingOrWritingAFile(string $option) + { + $repository = self::createFoobarRepository(false); + $file = self::createTempDir().'/secret'; + file_put_contents($file, "secret\n"); + + try { + $repository->getBlame($option.$file, 'README.md')->getLines(); + $this->fail('The option is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('reading or writing a file', $e->getMessage()); + } + } + + public static function provideBlameFileOptions() + { + yield 'contents' => ['--contents=']; + yield 'abbreviation of contents' => ['--con=']; + yield 'shortest abbreviation of contents, before git knew --color-lines' => ['--c=']; + yield 'ignore-revs-file' => ['--ignore-revs-file=']; + yield 'abbreviation of ignore-revs-file' => ['--ignore-revs=']; + yield 'revs-file' => ['-S']; + yield 'revs-file in a group' => ['-wS']; + yield 'output' => ['--output=']; + } + + /** + * @group legacy + */ + public function testCreatingAReferenceFromAnOptionIsRefused() + { + $repository = self::createFoobarRepository(); + $references = $repository->getReferences(); + + // "git update-ref refs/heads/foo -d" deletes it. + foreach (['createBranch' => 'refs/heads/foo', 'createTag' => 'refs/tags/foo'] as $method => $name) { + $repository->run('update-ref', [$name, self::INITIAL_COMMIT]); + + try { + $references->$method('foo', '-d'); + $this->fail('The dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame('"-d" starts with a dash, which git would read as an option.', $e->getMessage()); + } + + $this->assertSame(self::INITIAL_COMMIT, trim($repository->run('rev-parse', [$name]))); + } + + try { + $references->update(new Branch($repository, 'refs/heads/foo', '-d')); + $this->fail('The dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertSame(self::INITIAL_COMMIT, trim($repository->run('rev-parse', ['refs/heads/foo']))); + } + } + + public function testCheckoutNewBranchRefusesOptions() + { + $workingCopy = self::createFoobarRepository(false)->getWorkingCopy(); + + foreach ([['--orphan=foo', 'master'], ['foo', '--force']] as [$branch, $startPoint]) { + try { + $workingCopy->checkoutNewBranch($branch, $startPoint); + $this->fail('The dash is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } + } + } + + /** + * In a repository with a remote, "git ls-remote --upload-pack=..." runs + * the command to list the references of the remote. + * + * @group legacy + * + * @dataProvider provideUrlChecks + */ + public function testCheckingAUrlStartingWithADashRunsNothing(\Closure $check) + { + [$bare, $command, $file] = $this->createCommand(); + + $repository = self::createEmptyRepository(false); + $repository->run('remote', ['add', 'origin', $bare]); + + $cwd = getcwd(); + chdir($repository->getWorkingDir()); + + try { + $this->assertFalse($check('--upload-pack='.$command, $bare)); + } finally { + chdir($cwd); + } + + $this->assertFileIsAbsent($file); + } + + public static function provideUrlChecks() + { + $options = self::getOptions(); + $driver = self::getDriver(); + + yield 'Admin::isValidRepository()' => [fn (string $url) => Admin::isValidRepository($url, $options)]; + // The second argument is the repository for git. + yield 'Admin::isValidRepositoryAndBranch()' => [fn (string $url, string $bare) => Admin::isValidRepositoryAndBranch($url, $bare, $options)]; + yield 'Repository::isValidRepository()' => [fn (string $url) => Repository::isValidRepository($url, $driver)]; + yield 'Repository::isValidRepositoryAndBranch()' => [fn (string $url, string $bare) => Repository::isValidRepositoryAndBranch($url, $bare, $driver)]; + } + + /** + * The path is the repository for git. + * + * @group legacy + * + * @dataProvider provideClones + */ + public function testCloningAUrlStartingWithADashRunsNothing(\Closure $clone) + { + [$bare, $command, $file] = $this->createCommand(); + + try { + $clone('--upload-pack='.$command, $bare); + $this->fail('The URL is refused.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('starts with a dash', $e->getMessage()); + } + + $this->assertFileIsAbsent($file); + } + + public static function provideClones() + { + $options = self::getOptions(); + $driver = self::getDriver(); + + yield 'Admin::cloneTo()' => [fn (string $url, string $path) => Admin::cloneTo($path, $url, true, $options)]; + yield 'Admin::cloneBranchTo()' => [fn (string $url, string $path) => Admin::cloneBranchTo($path, $url, 'master', true, $options)]; + yield 'Admin::mirrorTo()' => [fn (string $url, string $path) => Admin::mirrorTo($path, $url, $options)]; + yield 'Admin::cloneRepository()' => [fn (string $url, string $path) => Admin::cloneRepository($path, $url, [], $options)]; + yield 'Repository::cloneFrom()' => [fn (string $url, string $path) => Repository::cloneFrom($url, $path, null, $driver)]; + yield 'Repository::cloneBareFrom()' => [fn (string $url, string $path) => Repository::cloneBareFrom($url, $path, null, $driver)]; + yield 'Repository::mirrorFrom()' => [fn (string $url, string $path) => Repository::mirrorFrom($url, $path, $driver)]; + } + + /** + * After the URL, git would read the path as an option too. + * + * @group legacy + */ + public function testAPathMayStartWithADash() + { + [$bare, $command, $file] = $this->createCommand(); + $dir = self::createTempDir(); + $option = '--upload-pack='.basename($command); + + $cwd = getcwd(); + chdir($dir); + + try { + $clone = Repository::cloneBareFrom($bare, $option, null, self::getDriver()); + $this->assertSame(realpath($dir.'/'.$option), $clone->getGitDir()); + + $this->assertInstanceOf(Repository::class, Admin::cloneTo('-clone', $bare, true, self::getOptions())); + $this->assertInstanceOf(Repository::class, Admin::init('-init', true, self::getOptions())); + $this->assertInstanceOf(Repository::class, Repository::init('-working-copy', self::getDriver())); + $this->assertInstanceOf(Repository::class, Repository::initBare('--separate-git-dir=foo', self::getDriver())); + } finally { + chdir($cwd); + } + + $this->assertFileIsAbsent($file); + $this->assertFileExists($dir.'/-clone/HEAD'); + $this->assertFileExists($dir.'/-init/HEAD'); + $this->assertFileExists($dir.'/-working-copy/.git/HEAD'); + $this->assertFileExists($dir.'/--separate-git-dir=foo/HEAD'); + } + + /** + * git talks through a file descriptor it inherits from PHP, and waits on it. + * + * @group legacy + */ + public function testAFileDescriptorUrlIsRefused() + { + $this->assertFalse(Admin::isValidRepository('fd::3', self::getOptions())); + $this->assertFalse(Admin::isValidRepositoryAndBranch('fd::3', 'master', self::getOptions())); + $this->assertFalse(Repository::isValidRepository('fd::3', self::getDriver())); + $this->assertFalse(Repository::isValidRepositoryAndBranch('fd::3', 'master', self::getDriver())); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('uses the "fd::" transport'); + + Repository::cloneFrom('fd::3', self::createTempDir().'/clone', null, self::getDriver()); + } + + /** + * @dataProvider provideHostsStartingWithADash + */ + public function testAHostStartingWithADashIsRefused(string $url) + { + $this->assertStringContainsString('starting with a dash', RemoteUrl::unsafeReason($url)); + } + + public static function provideHostsStartingWithADash() + { + $command = '-oProxyCommand=touch${IFS}pwned'; + + yield 'ssh' => ['ssh://'.$command.'/repository']; + yield 'git+ssh' => ['git+ssh://'.$command.'/repository']; + yield 'ssh+git' => ['ssh+git://'.$command.'/repository']; + yield 'host between brackets' => ['ssh://['.$command.']/repository']; + yield 'user' => ['ssh://'.$command.'@example.com/repository']; + yield 'host after a user' => ['ssh://git@'.$command.'/repository']; + yield 'address' => ['git@'.$command.':repository']; + yield 'address between brackets' => ['['.$command.']:repository']; + yield 'git, for the command of core.gitProxy' => ['git://'.$command.'/repository']; + yield 'encoded dash' => ['ssh://%2D'.substr($command, 1).'/repository']; + yield 'encoded dash, lowercase' => ['git+ssh://%2d'.substr($command, 1).'/repository']; + yield 'encoded dash of a user' => ['ssh://%2D'.substr($command, 1).'@example.com/repository']; + yield 'encoded brackets' => ['ssh://%5B'.$command.'%5D/repository']; + } + + /** + * @dataProvider provideUrlsGitReachesWithoutRunningACommand + */ + public function testAUrlGitReachesWithoutRunningACommandIsAccepted(string $url) + { + $this->assertNull(RemoteUrl::unsafeReason($url)); + } + + public static function provideUrlsGitReachesWithoutRunningACommand() + { + yield 'ssh' => ['ssh://git@example.com:2222/repository.git']; + yield 'host between brackets' => ['ssh://git@[::1]/repository.git']; + yield 'address' => ['git@example.com:repository.git']; + yield 'dash in the path of an address' => ['git@example.com:-repository.git']; + yield 'dash in the host' => ['ssh://git@my-host.example.com/repository.git']; + yield 'encoded character in the user' => ['ssh://first%2Dlast@example.com/repository.git']; + // Not reached through ssh: a token may start with a dash. + yield 'https' => ['https://-token@example.com/repository.git']; + yield 'local path' => ['/path/to/repository.git']; + yield 'local path with a colon' => ['./a:b']; + } + + /** + * Recent versions of git hide the credentials of an http URL in their + * error, not the old ones. + */ + public function testAFailedCloneHidesTheCredentials() + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The failing git is a shell script.'); + } + + $failingGit = self::createTempDir().'/git'; + file_put_contents($failingGit, "#!/bin/sh\necho \"fatal: unable to access '\$4'\" >&2\nexit 128\n"); + chmod($failingGit, 0700); + + try { + Repository::cloneFrom(self::URL, self::createTempDir().'/clone', null, new CliDriver($failingGit)); + $this->fail('The clone fails.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString("fatal: unable to access 'http://***@127.0.0.1:1/repository.git'", $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + } + } + + public function testARefusedUrlHidesTheCredentials() + { + $this->assertSame('"fd::http://***@host" uses the "fd::" transport, which talks through a file descriptor of PHP.', RemoteUrl::unsafeReason('fd::http://user:secret@host')); + } + + /** + * git writes the name of a branch it refuses in its error output, and the + * message holds the command line. + */ + public function testTheMessageOfAProcessExceptionHidesTheCredentials() + { + try { + self::createFoobarRepository()->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException $e) { + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + $this->assertStringNotContainsString('secret', $e->getMessage()); + $this->assertStringContainsString(self::URL, $e->getErrorOutput(), 'The error output is the one of git'); + } + } + + /** + * @group legacy + * + * @dataProvider provideSlowCalls + */ + public function testATimeoutKeepsItsClassAndHidesTheCredentials(\Closure $call) + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The slow git is a shell script.'); + } + + $slowGit = self::createTempDir().'/git'; + file_put_contents($slowGit, "#!/bin/sh\nexec sleep 10\n"); + chmod($slowGit, 0700); + + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + $call($slowGit, self::URL); + $this->fail('git times out.'); + } catch (ProcessTimedOutException $e) { + $this->assertStringContainsString('exceeded the timeout', $e->getMessage()); + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $e->getMessage()); + $this->assertStringNotContainsString('secret', (string) $e); + if (\PHP_VERSION_ID >= 80200) { + $this->assertTheTraceHidesTheCredentials($e); + } + } finally { + ini_set('zend.exception_ignore_args', $ignoreArgs); + } + } + + public static function provideSlowCalls() + { + yield 'Repository::run()' => [function (string $git, string $url) { + $repository = self::createEmptyRepository(); + (new Repository($repository->getGitDir(), null, new CliDriver($git, [], 0.2)))->run('fetch', [$url]); + }]; + yield 'Repository::cloneFrom()' => [function (string $git, string $url) { + Repository::cloneFrom($url, self::createTempDir().'/clone', null, new CliDriver($git, [], 0.2)); + }]; + yield 'Repository::isValidRepository()' => [function (string $git, string $url) { + Repository::isValidRepository($url, new CliDriver($git, [], 0.2)); + }]; + yield 'Repository::isValidRepositoryAndBranch()' => [function (string $git, string $url) { + Repository::isValidRepositoryAndBranch($url, 'master', new CliDriver($git, [], 0.2)); + }]; + yield 'Admin::isValidRepository()' => [function (string $git, string $url) { + Admin::isValidRepository($url, ['command' => $git, 'process_timeout' => 0.2]); + }]; + yield 'Admin::isValidRepositoryAndBranch()' => [function (string $git, string $url) { + Admin::isValidRepositoryAndBranch($url, 'master', ['command' => $git, 'process_timeout' => 0.2]); + }]; + } + + /** + * The exception of Symfony Process holds the command line: here, longer + * than what the system accepts. With PHP 8.1, which forks before running + * git, the process fails instead of not starting. + */ + public function testAProcessThatCannotStartHidesTheCredentials() + { + try { + // Over the 128 KiB of an argument on Linux. + self::createEmptyRepository()->run('fetch', [self::URL, str_repeat('a', 200 * 1024)]); + $this->fail('The command line is too long.'); + } catch (ExceptionInterface|ProcessException $e) { + $this->assertFalse(str_contains((string) $e, 'secret'), 'The message hides the credentials.'); + } + } + + /** + * @requires PHP 8.2 + * + * @group legacy + * + * @dataProvider provideFailingCalls + */ + public function testTheTracesHideTheCredentials(\Closure $call) + { + $ignoreArgs = ini_set('zend.exception_ignore_args', '0'); + + try { + $call(self::URL); + $this->fail('The command fails.'); + } catch (RuntimeException $e) { + $this->assertTheTraceHidesTheCredentials($e); + } finally { + ini_set('zend.exception_ignore_args', $ignoreArgs); + } + } + + public static function provideFailingCalls() + { + yield 'Repository::run()' => [function (string $url) { + self::createEmptyRepository()->run('fetch', [$url]); + }]; + yield 'Repository::cloneFrom()' => [function (string $url) { + Repository::cloneFrom($url, self::createTempDir().'/clone', null, self::getDriver()); + }]; + yield 'Repository::cloneBareFrom()' => [function (string $url) { + Repository::cloneBareFrom($url, self::createTempDir().'/clone', null, self::getDriver()); + }]; + yield 'Repository::mirrorFrom()' => [function (string $url) { + Repository::mirrorFrom($url, self::createTempDir().'/clone', self::getDriver()); + }]; + yield 'Admin::cloneTo()' => [function (string $url) { + Admin::cloneTo(self::createTempDir().'/clone', $url, true, self::getOptions()); + }]; + yield 'Admin::cloneBranchTo()' => [function (string $url) { + Admin::cloneBranchTo(self::createTempDir().'/clone', $url, 'master', true, self::getOptions()); + }]; + yield 'Admin::mirrorTo()' => [function (string $url) { + Admin::mirrorTo(self::createTempDir().'/clone', $url, self::getOptions()); + }]; + yield 'Admin::cloneRepository()' => [function (string $url) { + Admin::cloneRepository(self::createTempDir().'/clone', $url, [], self::getOptions()); + }]; + } + + public function testTheLogsHideTheCredentials() + { + $logger = new class() extends AbstractLogger { + public $messages = []; + + public function log($level, $message, array $context = []) + { + $this->messages[] = $message; + } + }; + + $source = self::createFoobarRepository(); + $options = self::getOptions(); + $repository = new Repository($source->getGitDir(), null, new CliDriver($options['command'], $options['environment_variables'], 60, $logger)); + $repository->run('config', ['remote.foo.url', self::URL]); + + // The arguments of the command, and its output. + $repository->run('remote', ['-v']); + try { + // Its error output. + $repository->run('branch', [self::URL]); + $this->fail('A URL is no valid branch name.'); + } catch (ProcessException $e) { + } + + $logs = implode("\n", $logger->messages); + $this->assertStringContainsString('http://***@127.0.0.1:1/repository.git', $logs); + $this->assertStringNotContainsString('secret', $logs); + } + + /** + * @dataProvider provideTexts + */ + public function testRedact(string $expected, string $text) + { + $this->assertSame($expected, Credentials::redact($text)); + } + + public static function provideTexts() + { + yield 'user and password' => ['https://***@example.com/a.git', 'https://user:secret@example.com/a.git']; + yield 'token' => ['https://***@example.com/a.git', 'https://secret@example.com/a.git']; + yield 'at sign in the password' => ['https://***@example.com/a.git', 'https://user:se@cret@example.com/a.git']; + yield 'hash in the password' => ['https://***@example.com/a.git', 'https://user:se#cret@example.com/a.git']; + yield 'question mark in the password' => ['https://***@example.com/a.git', 'https://user:se?cret@example.com/a.git']; + yield 'in a sentence' => ["fatal: unable to access 'https://***@example.com/': error", "fatal: unable to access 'https://user:secret@example.com/': error"]; + yield 'twice' => ['http://***@a http://***@b', 'http://u:p@a http://u:p@b']; + yield 'no credentials' => ['https://example.com/a.git', 'https://example.com/a.git']; + yield 'address' => ['git@example.com:a.git', 'git@example.com:a.git']; + yield 'an email after a URL' => ['https://example.com/ user@example.com', 'https://example.com/ user@example.com']; + } + + /** + * @dataProvider provideHookCalls + */ + public function testAHookNameLeadingOutOfTheHooksDirectoryIsRefused(\Closure $call, string $name) + { + $repository = self::createEmptyRepository(); + $hooks = $repository->getHooks(); + $config = file_get_contents($repository->getGitDir().'/config'); + + try { + $call($hooks, $name); + $this->fail('The name is refused.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('leads out of the hooks directory', $e->getMessage()); + } + + $this->assertSame($config, file_get_contents($repository->getGitDir().'/config')); + $this->assertFileIsAbsent($repository->getGitDir().'/created'); + } + + public static function provideHookCalls() + { + $calls = [ + 'has' => fn ($hooks, $name) => $hooks->has($name), + 'get' => fn ($hooks, $name) => $hooks->get($name), + 'remove' => fn ($hooks, $name) => $hooks->remove($name), + 'set' => fn ($hooks, $name) => $hooks->set($name, 'content'), + 'setSymlink' => fn ($hooks, $name) => $hooks->setSymlink($name, '/etc/hostname'), + ]; + + foreach ($calls as $method => $call) { + yield $method.' of the configuration' => [$call, '../config']; + yield $method.' of a new file' => [$call, '../created']; + yield $method.' through a directory' => [$call, 'foo/../../config']; + yield $method.' of the parent' => [$call, '..']; + yield $method.' with a backslash' => [$call, '..\\config']; + } + } + + /** + * A link to a file not deployed yet: set() wrote through it, out of the + * hooks directory. + */ + public function testADanglingLinkIsAHook() + { + $repository = self::createEmptyRepository(); + $hooks = $repository->getHooks(); + $target = self::createTempDir().'/missing'; + symlink($target, $repository->getGitDir().'/hooks/pre-commit'); + + $this->assertTrue($hooks->has('pre-commit')); + + try { + $hooks->set('pre-commit', 'content'); + $this->fail('The hook exists.'); + } catch (LogicException $e) { + $this->assertFileIsAbsent($target); + } + + try { + $hooks->setSymlink('pre-commit', '/etc/hostname'); + $this->fail('The hook exists.'); + } catch (LogicException $e) { + $this->assertSame($target, readlink($repository->getGitDir().'/hooks/pre-commit')); + } + + try { + $hooks->get('pre-commit'); + $this->fail('The hook has no content.'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Hook named "pre-commit" is not present', $e->getMessage()); + } + + $hooks->remove('pre-commit'); + $this->assertFalse(is_link($repository->getGitDir().'/hooks/pre-commit')); + } + + /** + * @dataProvider provideWorkingTreeReads + */ + public function testTheCommandOfCoreFsmonitorIsNotRun(\Closure $read) + { + $repository = self::createFoobarRepository(false); + [, $command, $file] = $this->createCommand(); + $repository->run('config', ['core.fsmonitor', $command]); + + $read($repository); + + $this->assertFileIsAbsent($file); + } + + public static function provideWorkingTreeReads() + { + yield 'WorkingCopy::getUntrackedFiles()' => [fn (Repository $repository) => $repository->getWorkingCopy()->getUntrackedFiles()]; + yield 'WorkingCopy::getDiffPending()' => [fn (Repository $repository) => $repository->getWorkingCopy()->getDiffPending()]; + yield 'WorkingCopy::getDiffStaged()' => [fn (Repository $repository) => $repository->getWorkingCopy()->getDiffStaged()]; + yield 'Commit::getDiff()' => [fn (Repository $repository) => $repository->getHeadCommit()->getDiff()]; + yield 'Repository::run()' => [fn (Repository $repository) => $repository->run('status')]; + } + + /** + * What gitlib sets comes after the values of the application, and of the + * "git -c" running PHP, which git passes through the same variable. + */ + public function testTheConfigurationParametersOfTheApplicationAreKept() + { + $source = self::createEmptyRepository(); + $options = self::getOptions(); + $repository = function (array $env) use ($source, $options) { + return new Repository($source->getGitDir(), null, new CliDriver($options['command'], $env + $options['environment_variables'])); + }; + + $this->assertSame("foo\n", $repository(['GIT_CONFIG_PARAMETERS' => "'user.name=foo'"])->run('config', ['user.name'])); + $this->assertSame("false\n", $repository(['GIT_CONFIG_PARAMETERS' => "'core.fsmonitor=/command'"])->run('config', ['core.fsmonitor'])); + $this->assertSame("false\n", $repository(['GIT_CONFIG_PARAMETERS' => false])->run('config', ['core.fsmonitor'])); + + putenv("GIT_CONFIG_PARAMETERS='user.name=inherited'"); + + try { + $this->assertSame("inherited\n", $source->run('config', ['user.name'])); + } finally { + putenv('GIT_CONFIG_PARAMETERS'); + } + } + + /** + * Unlike 2.0, 1.x reads them by default, as "git replace --graft" relies + * on them: the changelog of 1.7.1 points to this variable. + */ + public function testTheReplacementsOfObjectsCanBeIgnored() + { + $source = self::createFoobarRepository(); + $message = $source->getCommit(self::LONGMESSAGE_COMMIT)->getMessage(); + $source->run('replace', [self::LONGMESSAGE_COMMIT, self::INITIAL_COMMIT]); + + $read = function (array $environmentVariables) use ($source) { + $repository = new Repository($source->getGitDir(), null, new CliDriver(environmentVariables: $environmentVariables)); + + return $repository->getCommit(self::LONGMESSAGE_COMMIT)->getMessage(); + }; + + $this->assertNotSame($message, $read([])); + $this->assertSame($message, $read(['GIT_NO_REPLACE_OBJECTS' => '1'])); + } + + public function testDeletingASymbolicReferenceKeepsTheBranch() + { + $repository = self::createFoobarRepository(); + $repository->run('update-ref', ['refs/remotes/origin/master', self::INITIAL_COMMIT]); + $repository->run('symbolic-ref', ['refs/remotes/origin/HEAD', 'refs/remotes/origin/master']); + $repository->run('symbolic-ref', ['refs/heads/alias', 'refs/heads/master']); + + $references = $repository->getReferences(); + $references->delete('refs/remotes/origin/HEAD'); + $references->delete('refs/heads/alias'); + + $this->assertSame(self::INITIAL_COMMIT, trim($repository->run('rev-parse', ['refs/remotes/origin/master']))); + $this->assertStringNotContainsString('refs/remotes/origin/HEAD', $repository->run('show-ref')); + $this->assertStringContainsString('refs/heads/master', $repository->run('show-ref')); + $this->assertStringNotContainsString('refs/heads/alias', $repository->run('show-ref')); + } + + /** + * @group legacy + */ + public function testDeletingADetachedHeadIsRefused() + { + $repository = self::createFoobarRepository(false); + $repository->run('checkout', ['-q', self::LONGFILE_COMMIT]); + + try { + $repository->getReferences()->delete('HEAD'); + $this->fail('A detached HEAD is kept.'); + } catch (InvalidArgumentException $e) { + $this->assertFileExists($repository->getGitDir().'/HEAD'); + } + } + + private function assertTheTraceHidesTheCredentials(\Throwable $e): void + { + // The frames of gitlib: the ones of the test hold the URL too. + $frames = array_filter($e->getTrace(), fn (array $frame) => 1 === preg_match('/^Gitonomy\\\\Git\\\\(?!Tests)/', $frame['class'] ?? '')); + $args = array_merge(...array_map(fn (array $frame) => $frame['args'] ?? [], $frames)); + $this->assertNotSame([], $args, 'The trace holds the arguments'); + + array_walk_recursive($args, function ($arg) { + if (is_string($arg)) { + $this->assertStringNotContainsString('secret', $arg); + } + }); + } + + /** + * Creates a bare repository, and a command writing a file. + * + * @return string[] The path of the repository, of the command, and of the file + */ + private function createCommand(): array + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('The command is a shell script.'); + } + + $bare = self::createFoobarRepository()->getGitDir(); + $dir = self::createTempDir(); + $command = $dir.'/command'; + file_put_contents($command, "#!/bin/sh\ntouch '$dir/run'\nexit 1\n"); + chmod($command, 0700); + + return [$bare, $command, $dir.'/run']; + } + + private function assertFileIsAbsent(string $file): void + { + $this->assertFalse(file_exists($file), sprintf('"%s" does not exist.', $file)); + } +}