d2d.emegrendeles.hu/app/Services/Deployment/GitRepository.php
E98Developer 09eed3eeb3 ADD Deployment csomagoló phase1 védelem, git olvasó és read-only commit/diff felület
Két commit közötti fájlok összegyűjtését előkészítő felület első fázisa: célkörnyezet
választás (e2e/d2d), commitlista, diff-előnézet figyelmeztetésekkel. Ez a fázis még
semmit nem ír a fájlrendszerre, a csomagolás a phase2-ben érkezik.

- DeploymentGuard: .env kapcsoló + hardkódolt stage whitelist + developer szerep +
  feature flag; a flag szándékosan nem biztonsági réteg, csak láthatóság-vezérlés
- GitRepository: csak olvasó wrapper, argumentum-tömbös Symfony Process (nincs shell),
  hash- és referencia-validáció, core.quotePath=false az ékezetes útvonalakhoz
- ChangeSetAnalyzer: kizárási lista, törlendő/másolandó szétválasztás (átnevezésnél
  mindkettő), figyelmeztetések migrációra, composer.lock-ra és a nem verziókövetett
  fordított assetekre

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:45:09 +02:00

274 lines
8.5 KiB
PHP

<?php
namespace App\Services\Deployment;
use RuntimeException;
use Symfony\Component\Process\Process;
/**
* Csak olvasó git wrapper a deployment csomagolóhoz.
*
* Minden hívás argumentum-tömbbel megy a Symfony Process-nek, soha nem shell-stringgel,
* így a felületről érkező érték nem tud parancsot injektálni. Ettől függetlenül minden
* bemenetet külön is validálunk: a git a kötőjellel kezdődő értéket kapcsolóként
* értelmezné, a `..` pedig commit-tartományt jelent, nem fájlnevet.
*/
class GitRepository
{
/** Mezőelválasztó a git log formátumban (ASCII unit separator). */
private const FIELD_SEPARATOR = "\x1f";
/** Rekordelválasztó a git log formátumban (ASCII record separator). */
private const RECORD_SEPARATOR = "\x1e";
private const COMMIT_PATTERN = '/^[0-9a-f]{7,40}$/';
private const REFERENCE_PATTERN = '#^[A-Za-z0-9][A-Za-z0-9._/-]*$#';
public function isAvailable(): bool
{
try {
$this->run(['rev-parse', '--git-dir']);
return true;
} catch (RuntimeException) {
return false;
}
}
public function currentBranch(): ?string
{
try {
$branch = trim($this->run(['rev-parse', '--abbrev-ref', 'HEAD']));
} catch (RuntimeException) {
return null;
}
return ($branch === '' || $branch === 'HEAD') ? null : $branch;
}
/**
* @return array<int, string>
*/
public function branches(): array
{
$output = $this->run([
'for-each-ref',
'--format=%(refname:short)',
'--sort=-committerdate',
'refs/heads',
'refs/remotes',
]);
return collect(explode("\n", $output))
->map(fn (string $line): string => trim($line))
->filter()
// Az "origin/HEAD" csak egy mutató az alapértelmezett branchre, nem önálló ág.
->reject(fn (string $branch): bool => str_ends_with($branch, '/HEAD'))
->unique()
->values()
->all();
}
/**
* Commitlista egy referenciáról, legfrissebbtől visszafelé.
*
* A --shortstat miatt minden commit után megjelenik a "N files changed" sor is,
* ezt az utolsó mezőből (a tárgyból) bányásszuk ki - így nem kell commitonként
* külön git hívás a fájlszámhoz.
*
* @return array<int, array{hash:string, short:string, author:string, date:string, subject:string, file_count:?int}>
*/
public function commits(string $reference, int $limit): array
{
$this->assertReference($reference);
$format = self::RECORD_SEPARATOR.implode(self::FIELD_SEPARATOR, ['%H', '%h', '%an', '%aI', '%s']);
$output = $this->run([
'log',
'--max-count='.max(1, min($limit, 500)),
'--no-merges',
'--shortstat',
'--pretty=format:'.$format,
$reference,
'--',
]);
$commits = [];
foreach (explode(self::RECORD_SEPARATOR, $output) as $record) {
if (trim($record) === '') {
continue;
}
$fields = explode(self::FIELD_SEPARATOR, $record, 5);
if (count($fields) < 5) {
continue;
}
[$hash, $short, $author, $date, $tail] = $fields;
$commits[] = [
'hash' => $hash,
'short' => $short,
'author' => $author,
'date' => $date,
'subject' => trim(explode("\n", $tail, 2)[0]),
'file_count' => preg_match('/(\d+)\s+files?\s+changed/', $tail, $matches) === 1
? (int) $matches[1]
: null,
];
}
return $commits;
}
/**
* Teljes hash, ha a megadott érték létező commitra mutat - különben null.
*/
public function resolveCommit(string $hash): ?string
{
if (preg_match(self::COMMIT_PATTERN, $hash) !== 1) {
return null;
}
try {
$resolved = trim($this->run(['rev-parse', '--verify', '--quiet', $hash.'^{commit}']));
} catch (RuntimeException) {
return null;
}
return $resolved === '' ? null : $resolved;
}
/**
* Őse-e az első commit a másodiknak? (Fordított tartomány felismeréséhez.)
*/
public function isAncestor(string $ancestor, string $descendant): bool
{
if (! $this->resolveCommit($ancestor) || ! $this->resolveCommit($descendant)) {
return false;
}
// A merge-base 1-es kilépési kóddal jelzi a "nem őse" esetet, ez nem hiba,
// ezért itt nem a run() dobó változatát használjuk.
return $this->process(['merge-base', '--is-ancestor', $ancestor, $descendant])->run() === 0;
}
/**
* A két commit között változott fájlok (from kizárva, to beleértve).
*
* @return array<int, array{status:string, path:string, old_path:?string}>
*/
public function changedFiles(string $from, string $to): array
{
$fromHash = $this->resolveCommit($from);
$toHash = $this->resolveCommit($to);
if (! $fromHash || ! $toHash) {
throw new RuntimeException('Ismeretlen commit azonosító.');
}
// A core.quotePath=false nélkül a git a nem-ASCII fájlneveket idézőjelbe teszi
// és oktálisan escape-eli ("app/\303\251kezet.php") - ékezetes útvonalaknál ez
// használhatatlan lenne.
$output = $this->run([
'-c', 'core.quotePath=false',
'diff',
'--name-status',
'--find-renames',
$fromHash.'..'.$toHash,
'--',
]);
$files = [];
foreach (explode("\n", $output) as $line) {
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}
$parts = explode("\t", $line);
$status = strtoupper(substr($parts[0], 0, 1));
// Átnevezés (R) és másolás (C) esetén két útvonal jön: régi és új.
$isTwoPath = in_array($status, ['R', 'C'], true) && count($parts) >= 3;
$path = $isTwoPath ? $parts[2] : ($parts[1] ?? '');
$oldPath = $isTwoPath ? $parts[1] : null;
if (! $this->isSafePath($path) || ($oldPath !== null && ! $this->isSafePath($oldPath))) {
continue;
}
$files[] = [
'status' => $status,
'path' => $path,
'old_path' => $oldPath,
];
}
usort($files, fn (array $a, array $b): int => strcmp($a['path'], $b['path']));
return $files;
}
/**
* A repo gyökeréből ki nem mutató, relatív útvonal-e.
*/
private function isSafePath(string $path): bool
{
if ($path === '' || str_starts_with($path, '/') || str_contains($path, "\0")) {
return false;
}
// Windows meghajtó-előtag (C:/...) és szülőkönyvtár-hivatkozás sem fordulhat elő
// valódi git útvonalban, viszont fájlkiírásnál kitörhetne a célmappából.
return preg_match('#^[A-Za-z]:#', $path) !== 1
&& preg_match('#(^|/)\.\.(/|$)#', $path) !== 1;
}
private function assertReference(string $reference): void
{
if (preg_match(self::REFERENCE_PATTERN, $reference) !== 1 || str_contains($reference, '..')) {
throw new RuntimeException(sprintf('Érvénytelen git referencia: %s', $reference));
}
}
/**
* @param array<int, string> $arguments
*/
private function process(array $arguments): Process
{
return new Process(
array_merge([(string) config('deployment.git_binary') ?: 'git'], $arguments),
(string) config('deployment.repo_path') ?: base_path(),
null,
null,
(float) config('deployment.timeout', 60),
);
}
/**
* @param array<int, string> $arguments
*/
private function run(array $arguments): string
{
$process = $this->process($arguments);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(sprintf(
'A git parancs hibára futott (%s): %s',
implode(' ', $arguments),
trim($process->getErrorOutput()) ?: trim($process->getOutput()),
));
}
return $process->getOutput();
}
}