Fix-Runde 1: bind-hosts schreibt keine Rueckverweise mehr in die .env

apply() ersetzte eine vorhandene Zeile per preg_replace($pattern, $line, ...)
- $line kommt von der Befehlszeile, und preg_replace deutet $1/\1/\\ im ERSATZ
als Rueckverweis, auch ohne Klammern im Muster. Ein Hostname mit solchen
Zeichen wuerde still verstuemmelt in die Datei geschrieben, die jedes
Geheimnis der Installation haelt - und EnvFileEditor::isValidLine() kann das
nicht fangen, das Ergebnis ist syntaktisch weiter KEY=value. apply() arbeitet
jetzt zeilenweise ohne jede Regex im Ersatzpfad.

Zusaetzlich: EnvFileEditor::write() wirft InvalidEnvContentException, wenn der
neue Inhalt nicht parst - das war ungefangen und zeigte dem Betreiber einen
Stapelabzug auf der Zugangsdatendatei statt eines Satzes wie jeder andere
Fehlerpfad in diesem Befehl.

Neuer Test deckt den Rueckverweis-Fall ab, ueber die bereits vorhandene
(leere) SITE_HOST-Zeile - der Pfad, den preg_replace tatsaechlich traf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 15:24:35 +02:00
parent 5c9ee2c6d5
commit 6bb705e566
2 changed files with 51 additions and 8 deletions

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use Illuminate\Console\Command;
/**
@ -95,7 +96,16 @@ class BindHosts extends Command
return self::FAILURE;
}
$backup = $env->write($this->apply($content, $missing));
try {
$backup = $env->write($this->apply($content, $missing));
} catch (InvalidEnvContentException $e) {
// Abgelehnt heißt hier: NICHTS geschrieben, und die Sicherung ist
// erst gar nicht angelegt worden — der Editor prüft vor beidem.
// Der Betreiber soll das als Satz erfahren, nicht als Stapelabzug.
$this->error('Die Datei wurde nicht geschrieben: '.$e->getMessage());
return self::FAILURE;
}
$this->info('Geschrieben. Die vorherige Fassung liegt unter '.$backup);
$this->line('Danach: php artisan config:cache && php artisan route:cache');
@ -124,6 +134,12 @@ class BindHosts extends Command
/**
* Vorhandene Zeile ersetzen, sonst anhängen.
*
* Zeilenweise und ohne `preg_replace`: der Wert kommt von der Befehlszeile,
* und `preg_replace` deutet `$1`, `\1` und `\\` im ERSATZ als Rückverweise.
* Aus `--site 'www.example.test$1'` würde still `SITE_HOST=www.example.test`
* und diese Datei hält jedes Geheimnis dieser Installation. Ein leise
* verstümmelter Wert darin ist schlimmer als eine Fehlermeldung.
*
* Ersetzen und nicht nur anhängen, weil ein leerer Schlüssel als fehlend
* gilt: `APP_HOST=` steht dann schon da, und ein zweites `APP_HOST=…`
* darunter wäre eine Datei mit zwei Antworten auf dieselbe Frage.
@ -132,15 +148,24 @@ class BindHosts extends Command
*/
private function apply(string $content, array $values): string
{
foreach ($values as $key => $value) {
$line = $key.'='.$value;
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
$lines = preg_split('/\R/', rtrim($content, "\r\n"));
$content = preg_match($pattern, $content) === 1
? preg_replace($pattern, $line, $content, 1)
: rtrim($content, "\n")."\n".$line."\n";
foreach ($values as $key => $value) {
$replaced = false;
foreach ($lines as $index => $line) {
if (str_starts_with($line, $key.'=')) {
$lines[$index] = $key.'='.$value;
$replaced = true;
break;
}
}
if (! $replaced) {
$lines[] = $key.'='.$value;
}
}
return $content;
return implode("\n", $lines)."\n";
}
}

View File

@ -110,3 +110,21 @@ it('sagt es, wenn schon alles steht, statt eine Sicherung anzulegen', function (
expect(glob($this->envPath.'.bak-*'))->toBeEmpty();
});
it('schreibt einen Wert wörtlich, auch wenn er wie ein Rückverweis aussieht', function () {
// SITE_HOST steht schon leer da — genau das, was .env.example wirklich
// ausliefert, und der Pfad, auf dem apply() eine vorhandene Zeile per
// preg_replace ersetzt statt sie nur anzuhängen.
file_put_contents($this->envPath, "APP_URL=https://app.example.test\nSITE_HOST=\n");
// `$1` und `\1` sind das, was preg_replace im ERSATZ als Rückverweis
// deutet und still verschluckt. Diese Datei hält jedes Geheimnis der
// Installation — was hier ankommt, muss ankommen wie eingegeben.
$this->artisan('clupilot:bind-hosts', [
'--site' => 'www.example.test$1,foo\1bar',
'--force' => true,
])->assertSuccessful();
expect(file_get_contents($this->envPath))
->toContain('SITE_HOST=www.example.test$1,foo\1bar');
});