Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Backup\BackupRestorer;
use OpenNIT\Backup\DatabaseDumper;
use OpenNIT\Backup\SelfBackupManager;
/**
* Deckt die Sicherung der eigenen Installation ab.
*
* Eine Sicherung enthält alles, was die Installation ausmacht: den Inhalt der
* Datenbank samt Tresor und – wenn die Konfiguration mitgeht – den Schlüssel,
* mit dem sich dieser lesen lässt. Daraus folgen die beiden Zusagen, die hier
* festgehalten werden: Der Ablageort darf nicht im Webroot liegen, und die
* Konfiguration ist nicht von sich aus dabei.
*
* Der dritte Punkt ist die Prüfung. Eine Sicherung, die niemand je geöffnet
* hat, ist eine Vermutung – die Prüfung liest deshalb jeden Eintrag wirklich,
* und der Rückweg wird hier gegen ein echtes Archiv und eine echte Datenbank
* gefahren, nicht gegen eine Zeichenkette.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
$tmp = sys_get_temp_dir() . '/onit-backup-test-' . getmypid();
@mkdir($tmp, 0700, true);
/** Räumt ein Verzeichnis samt Inhalt ab. */
$weg = static function (string $pfad) use (&$weg): void {
if (is_dir($pfad)) {
foreach (scandir($pfad) ?: [] as $eintrag) {
if ($eintrag !== '.' && $eintrag !== '..') {
$weg($pfad . '/' . $eintrag);
}
}
@rmdir($pfad);
} elseif (is_file($pfad)) {
@unlink($pfad);
}
};
// ----------------------------------------------------------------
// Rückweg: SQL einspielen
// ----------------------------------------------------------------
test('Ein Abzug lässt sich wieder einspielen', function () {
// Gegen eine echte Datenbank, nicht gegen eine Zeichenkette: Erst das
// zeigt, ob die Anweisungen so getrennt werden, wie sie geschrieben wurden.
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$sql = "-- OpenNIT – Sicherung der Datenbank\n"
. "\n"
. "CREATE TABLE geraete (\n"
. " id INTEGER PRIMARY KEY,\n"
. " name TEXT\n"
. ");\n"
. "INSERT INTO geraete (id,name) VALUES\n"
. "(1,'Erstes'),\n"
. "(2,'Zweites; mit Strichpunkt'),\n"
. "(3,'Drittes\nmit Umbruch');\n"
. "\n"
. DatabaseDumper::END_MARKER . "\n";
$strom = fopen('php://memory', 'r+');
fwrite($strom, $sql);
rewind($strom);
$ergebnis = BackupRestorer::applySql($pdo, $strom);
fclose($strom);
assertSame(0, $ergebnis['failed'], 'Eine Anweisung ist gescheitert');
assertSame(2, $ergebnis['executed'], 'Erwartet wurden CREATE und INSERT');
$zeilen = $pdo->query('SELECT name FROM geraete ORDER BY id')->fetchAll(PDO::FETCH_COLUMN);
assertSame(3, count($zeilen), 'Nicht alle Zeilen kamen an');
// Ein Strichpunkt mitten im Wert darf die Anweisung nicht zerteilen.
assertSame('Zweites; mit Strichpunkt', $zeilen[1]);
// Ein Zeilenumbruch im Wert ebenso wenig.
assertSame("Drittes\nmit Umbruch", $zeilen[2]);
});
test('Ein Kommentar zwischen zwei Anweisungen stört nicht', function () {
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$strom = fopen('php://memory', 'r+');
fwrite($strom, "CREATE TABLE a (id INTEGER);\n-- Tabelle `b`\nCREATE TABLE b (id INTEGER);\n");
rewind($strom);
$ergebnis = BackupRestorer::applySql($pdo, $strom);
fclose($strom);
assertSame(2, $ergebnis['executed']);
assertSame(0, $ergebnis['failed']);
});
// ----------------------------------------------------------------
// Rückweg: Dateien aus dem Archiv
// ----------------------------------------------------------------
test('Ein Eintrag mit Pfadwechsel landet nicht außerhalb des Ziels', function () use ($tmp, $weg) {
// Der Name kommt aus dem Archiv und damit nicht zwingend aus eigener Hand.
if (!class_exists('ZipArchive')) {
return;
}
$archiv = $tmp . '/boese.zip';
$ziel = $tmp . '/ziel';
@mkdir($ziel, 0700, true);
$zip = new ZipArchive();
$zip->open($archiv, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('storage/brav.txt', 'ok');
$zip->addFromString('storage/../../entwischt.txt', 'nicht ok');
$zip->addFromString('storage/tief/../../auch-entwischt.txt', 'nicht ok');
$zip->close();
$zip = new ZipArchive();
$zip->open($archiv);
$anzahl = BackupRestorer::extract($zip, 'storage/', $ziel);
$zip->close();
assertSame(1, $anzahl, 'Es wurde mehr als die brave Datei geschrieben');
assertTrue(is_file($ziel . '/brav.txt'), 'Die brave Datei fehlt');
assertFalse(is_file(dirname($ziel, 2) . '/entwischt.txt'), 'Eine Datei ist aus dem Ziel entwischt');
assertFalse(is_file(dirname($ziel) . '/auch-entwischt.txt'), 'Eine Datei ist aus dem Ziel entwischt');
$weg($ziel);
@unlink($archiv);
});
test('Einzelne Namen lassen sich anderswohin schreiben', function () use ($tmp, $weg) {
// `.env` liegt nicht in `config/`, kommt im Archiv aber von dort.
if (!class_exists('ZipArchive')) {
return;
}
$archiv = $tmp . '/conf.zip';
$ziel = $tmp . '/conf';
@mkdir($ziel, 0700, true);
$zip = new ZipArchive();
$zip->open($archiv, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('config/app.php', '<?php return [];');
$zip->addFromString('config/.env', 'APP_KEY=x');
$zip->close();
$zip = new ZipArchive();
$zip->open($archiv);
$anzahl = BackupRestorer::extract($zip, 'config/', $ziel, ['.env' => $tmp . '/.env']);
$zip->close();
assertSame(2, $anzahl);
assertTrue(is_file($ziel . '/app.php'), 'app.php fehlt');
assertTrue(is_file($tmp . '/.env'), '.env ging nicht an seinen eigenen Ort');
assertFalse(is_file($ziel . '/.env'), '.env liegt zusätzlich am falschen Ort');
$weg($ziel);
@unlink($tmp . '/.env');
@unlink($archiv);
});
// ----------------------------------------------------------------
// Ablageort
// ----------------------------------------------------------------
test('Ein Ablageort im Webroot wird abgewiesen', function () use ($quelle) {
// Eine Sicherung, die sich über die eigene Adresse herunterladen lässt,
// ist keine.
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'public function pruefeZielverzeichnis(');
assertTrue($start !== false, 'Die Prüfung fehlt');
$body = substr($src, $start, 1400);
assertTrue(str_contains($body, "realpath(\$this->rootDir . '/public')"), 'Der Webroot wird nicht bestimmt');
assertTrue(str_contains($body, 'str_starts_with($this->aufgeloest($pfad)'), 'Die Lage wird nicht geprüft');
assertTrue(str_contains($body, 'liegt im Webroot'), 'Der Grund wird nicht benannt');
// Die Lage steht vor dem Anlegen – sonst bliebe ein abgewiesener Ort
// als leeres Verzeichnis zurück.
$lage = strpos($body, 'liegt im Webroot');
$anlegen = strpos($body, '@mkdir(');
assertTrue($lage !== false && $anlegen !== false && $lage < $anlegen, 'Es wird vor der Prüfung angelegt');
// Und die Prüfung steht vor jedem Schreiben.
$run = substr($src, (int)strpos($src, 'public function run('), 1200);
$p = strpos($run, 'pruefeZielverzeichnis(');
$i = strpos($run, "insert('self_backups'");
assertTrue($p !== false && $i !== false && $p < $i, 'Es wird vor der Prüfung geschrieben');
});
test('Ein abgewiesener Ort bleibt nicht als Verzeichnis zurück', function () use ($wurzel) {
// Ein Webroot-Pfad wird verworfen – er darf dort aber auch kein leeres
// Verzeichnis hinterlassen, das beim nächsten Blick verwirrt.
$ref = new ReflectionClass(\OpenNIT\Backup\SelfBackupManager::class);
$m = $ref->newInstanceWithoutConstructor();
$prop = $ref->getProperty('rootDir');
$prop->setAccessible(true);
$prop->setValue($m, $wurzel);
$pruefen = $ref->getMethod('pruefeZielverzeichnis');
foreach ([
$wurzel . '/public/sicherungen-testfall',
// Auch der Umweg über `..` führt in den Webroot.
$wurzel . '/public/../public/sicherungen-testfall',
$wurzel . '/storage/../public/sicherungen-testfall',
] as $pfad) {
$ergebnis = $pruefen->invoke($m, $pfad);
assertFalse($ergebnis['ok'], 'Angenommen: ' . $pfad);
assertTrue(str_contains($ergebnis['message'], 'Webroot'), 'Der Grund wird nicht benannt');
assertFalse(is_dir($pfad), 'Es blieb ein Verzeichnis zurück: ' . $pfad);
}
assertFalse(is_dir($wurzel . '/public/sicherungen-testfall'), 'Im Webroot blieb etwas zurück');
});
test('Ein relativer Ablageort wird abgewiesen', function () use ($wurzel) {
$ref = new ReflectionClass(\OpenNIT\Backup\SelfBackupManager::class);
$m = $ref->newInstanceWithoutConstructor();
$prop = $ref->getProperty('rootDir');
$prop->setAccessible(true);
$prop->setValue($m, $wurzel);
$ergebnis = $ref->getMethod('pruefeZielverzeichnis')->invoke($m, 'storage/backups');
assertFalse($ergebnis['ok'], 'Ein relativer Pfad wurde angenommen');
assertTrue(str_contains($ergebnis['message'], 'absoluter Pfad'), 'Der Grund wird nicht benannt');
});
test('Der Ablageort wird nicht aus dem Upload-Pfad abgeleitet', function () use ($quelle) {
// Der darf laut Konfiguration ganz woanders liegen; sein
// Elternverzeichnis wäre dann ein fremder Ort.
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'public function standardZiel(');
$body = substr($src, $start, 200);
assertFalse(str_contains($body, 'dirname(Paths::uploads'), 'Der Standard hängt am Upload-Pfad');
assertTrue(str_contains($body, "\$this->rootDir . '/storage/backups'"), 'Der Standard stimmt nicht');
});
test('Uploads gehen einen eigenen Weg ins Archiv und zurück', function () use ($quelle) {
// Sie dürfen anderswo liegen als im Projektverzeichnis, und beim
// Zurückspielen zählt der Ort DIESER Installation.
$src = $quelle('src/Backup/SelfBackupManager.php');
assertTrue(str_contains($src, "Paths::uploads(\$this->config), 'uploads', []"), 'Uploads werden nicht eigens gepackt');
assertTrue(str_contains($src, "'uploads'"), 'Der eigene Zweig fehlt');
$cli = $quelle('bin/restore-backup.php');
assertTrue(str_contains($cli, "extract(\$zip, 'uploads/', \$uploadsZiel"), 'Uploads werden nicht eigens zurückgeschrieben');
assertTrue(str_contains($cli, '$uploadsZiel = Paths::uploads($ctx->config);'), 'Das Ziel stammt nicht aus dieser Installation');
});
// ----------------------------------------------------------------
// Was mitgeht
// ----------------------------------------------------------------
test('Die Konfiguration ist nicht von sich aus dabei', function () use ($quelle) {
// Sie enthält den Anwendungsschlüssel; damit ließe sich der Tresor aus
// derselben Sicherung entschlüsseln.
foreach ([
'src/Controllers/Admin/SelfBackupAdminController.php',
'src/Cron/Handlers/SelfBackupHandler.php',
] as $datei) {
assertTrue(
str_contains($quelle($datei), "\$teil === 'config' ? '0' : '1'"),
$datei . ': Die Konfiguration ist vorausgewählt'
);
}
});
test('Zwischenspeicher und Sicherungen selbst bleiben draußen', function () use ($quelle) {
// Ohne das wüchse jede Sicherung um alle vorherigen.
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'private const STORAGE_SKIP');
$zeile = substr($src, $start, 160);
foreach (['cache', 'tmp', 'sessions', 'backups', 'logs', 'uploads'] as $name) {
assertTrue(str_contains($zeile, "'" . $name . "'"), $name . ' fehlt in der Ausnahmeliste');
}
});
// ----------------------------------------------------------------
// Prüfung und Aufbewahrung
// ----------------------------------------------------------------
test('Die Prüfung liest jeden Eintrag wirklich', function () use ($quelle) {
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'public function pruefeDatei(');
$body = substr($src, $start, 3000);
assertTrue(str_contains($body, 'hash_file('), 'Die Prüfsumme wird nicht verglichen');
assertTrue(str_contains($body, 'ZipArchive::CHECKCONS'), 'Das Archiv wird nicht auf Schlüssigkeit geöffnet');
assertTrue(str_contains($body, '$zip->getStream($name)'), 'Die Einträge werden nicht gelesen');
assertTrue(str_contains($body, 'DatabaseDumper::END_MARKER'), 'Die Vollständigkeit des Abzugs wird nicht geprüft');
});
test('Ein gescheiterter Lauf verdrängt keine geglückte Sicherung', function () use ($quelle) {
$src = $quelle('src/Backup/SelfBackupManager.php');
$start = strpos($src, 'public function raeumeAuf(');
$body = substr($src, $start, 900);
assertTrue(str_contains($body, "status = 'ok'"), 'Die Aufbewahrung zählt auch Fehlschläge mit');
});
test('Der Abzug endet mit einer erkennbaren Schlusszeile', function () use ($quelle) {
// Erst sie unterscheidet eine vollständige Datei von einer abgebrochenen.
$src = $quelle('src/Backup/DatabaseDumper.php');
assertTrue(str_contains($src, 'public const END_MARKER'), 'Es gibt keine Schlusszeile');
assertTrue(str_contains($src, 'fwrite($fh, self::END_MARKER'), 'Sie wird nicht geschrieben');
});
// ----------------------------------------------------------------
// Zugang und Protokoll
// ----------------------------------------------------------------
test('Alles hängt an einem eigenen Recht', function () use ($quelle) {
// Wer Sicherungen anlegt und herunterlädt, hält den gesamten
// Datenbestand in der Hand. Wer sie zurückspielt, ersetzt ihn – das ist
// ein zweites Recht.
$ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
assertSame(
substr_count($ctrl, 'public function '),
substr_count($ctrl, "authorize('manage_selfbackup')")
+ substr_count($ctrl, "authorize('restore_selfbackup')"),
'Nicht jede öffentliche Aktion prüft das Recht'
);
$app = $quelle('src/Core/Application.php');
foreach (['index', 'saveSettings', 'run', 'download', 'verify', 'destroy', 's3Test', 's3Lock',
'restore', 'fetch'] as $aktion) {
assertTrue(
str_contains($app, "SelfBackupAdminController@" . $aktion . "'"),
'Route fehlt: ' . $aktion
);
}
// Jede Route der Datensicherung hängt an einem der beiden Rechte –
// gezählt, damit eine später hinzugefügte nicht ohne auskommt.
assertSame(
substr_count($app, 'SelfBackupAdminController@'),
substr_count($app, "'capability' => 'manage_selfbackup'")
+ substr_count($app, "'capability' => 'restore_selfbackup'"),
'Nicht jede Route ist gesichert'
);
});
test('Das Herunterladen steht eigens im Protokoll', function () use ($quelle) {
// Damit verlässt der gesamte Datenbestand die Installation; ein
// Seitenaufruf ist das nicht.
$ctrl = $quelle('src/Controllers/Admin/SelfBackupAdminController.php');
$start = strpos($ctrl, 'public function download(');
$body = substr($ctrl, $start, 1200);
assertTrue(str_contains($body, 'selfbackup_downloaded'), 'Der Download wird nicht protokolliert');
assertTrue(str_contains($body, "'high'"), 'Der Vorgang gilt nicht als schwerwiegend');
// Und zwar bevor die Datei hinausgeht.
$p = strpos($body, 'selfbackup_downloaded');
$r = strpos($body, 'readfile(');
assertTrue($p !== false && $r !== false && $p < $r, 'Es wird erst ausgeliefert, dann protokolliert');
});
test('Der Weg über die Kommandozeile bleibt bestehen', function () use ($quelle, $wurzel) {
// Wo eine Shell zur Verfügung steht, ist sie der ruhigste Weg – und sie
// nutzt dieselben Schreibvorgänge wie das Backend.
assertTrue(is_file($wurzel . '/bin/restore-backup.php'), 'Das Skript fehlt');
$cli = $quelle('bin/restore-backup.php');
assertTrue(str_contains($cli, '--dry-run'), 'Es gibt keinen Probelauf');
assertTrue(str_contains($cli, 'pruefeDatei('), 'Das Archiv wird vor dem Schreiben nicht geprüft');
assertTrue(str_contains($cli, "\$eingabe !== 'JA'"), 'Es wird nicht ausdrücklich bestätigt');
// Die Reihenfolge: prüfen, bestätigen, schreiben.
$p = strpos($cli, 'pruefeDatei(');
$b = strpos($cli, 'Zum Fortfahren');
$s = strpos($cli, "in_array('database', \$zuTun, true)");
assertTrue($p < $b && $b < $s, 'Die Reihenfolge stimmt nicht');
assertTrue(str_contains($cli, 'BackupRestorer::applySql'), 'Das Skript geht einen eigenen Weg');
assertTrue(
str_contains($quelle('src/Backup/RestoreRunner.php'), 'BackupRestorer::applySql'),
'Das Backend geht einen eigenen Weg'
);
});
// ----------------------------------------------------------------
// Einrichtung
// ----------------------------------------------------------------
test('Tabelle, Recht und Zeitplan sind angelegt', function () use ($quelle) {
$sql = $quelle('database/migrations/222_self_backup.sql');
assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `self_backups`'), 'Die Tabelle fehlt');
assertTrue(str_contains($sql, "INSERT IGNORE INTO `capabilities`"), 'Das Recht wird nicht angelegt');
assertTrue(str_contains($sql, 'manage_selfbackup'), 'Das Recht heißt anders');
assertTrue(str_contains($sql, "INSERT IGNORE INTO `cron_tasks`"), 'Der Zeitplan wird nicht angelegt');
assertTrue(str_contains($sql, 'SelfBackupHandler'), 'Der Zeitplan kennt seinen Handler nicht');
// Standardmäßig aus: Ohne geprüften Ablageort liefe er ins Leere.
$zeitplan = substr($sql, (int)strpos($sql, "'self_backup', 'Datensicherung"));
assertTrue(str_contains($zeitplan, 'SelfBackupHandler\', 1440, 0,'), 'Der Zeitplan ist von sich aus an');
// `trigger` ist ein reserviertes Wort – die Spalte heißt anders.
assertFalse(str_contains($sql, '`trigger`'), 'Eine Spalte heißt wie ein reserviertes Wort');
});
$weg($tmp);
};