++ consolidamento funzionalità Backup

This commit is contained in:
2026-07-29 16:34:11 +02:00
parent fd15903c9c
commit 7ac454cd25
16 changed files with 585 additions and 30 deletions

View File

@@ -92,10 +92,12 @@ class BackupArchive
'files_count' => $totale,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
if (! $zip->close()) {
try {
$this->chiudi($zip);
} catch (\Throwable $e) {
@unlink($destinazione);
throw new RuntimeException('Scrittura del file di backup non riuscita.');
throw $e;
}
if ($totale === 0) {
@@ -116,7 +118,19 @@ class BackupArchive
foreach ($files as $relativePath) {
// addFile() legge dal disco durante close(): i PDF non
// passano mai interi per la memoria del processo.
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
//
// Il valore di ritorno si controlla: se il file e' sparito
// fra allFiles() e qui — cancellato da un'altra richiesta,
// o illeggibile per i permessi — addFile() torna false
// senza sollevare, e l'archivio finirebbe con un buco di
// cui nessuno si accorge fino al giorno del ripristino.
if (! $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
throw new RuntimeException(
'Impossibile aggiungere "' . $relativePath . '" al backup: '
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
);
}
$nelLotto++;
$fatti++;
@@ -133,11 +147,62 @@ class BackupArchive
throw $e;
}
try {
$this->verificaArchivio($destinazione, $totale);
} catch (\Throwable $e) {
@unlink($destinazione);
throw $e;
}
$onProgress && $onProgress($totale, $totale);
return $destinazione;
}
/**
* Rilegge l'archivio appena scritto e controlla che contenga quello che
* dichiara.
*
* Un backup e' l'unico file di cui non ci si accorge che era rotto finche'
* non serve. Costa una riapertura e la lettura della directory centrale —
* non dei contenuti — quindi e' un controllo a buon mercato che copre
* troncamenti, chiusure a meta' e lotti persi.
*/
private function verificaArchivio(string $path, int $fileAttesi): void
{
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::RDONLY) !== true) {
throw new RuntimeException('Il backup appena creato non e\' rileggibile.');
}
$trovati = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
$trovati++;
}
}
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
$zip->close();
if (! $haDati || ! $haManifest) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
}
if ($trovati !== $fileAttesi) {
throw new RuntimeException(sprintf(
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
$trovati,
$fileAttesi
));
}
}
/**
* Apre l'archivio in append, esegue il callback e lo chiude, garantendo la
* chiusura anche se il callback solleva.
@@ -152,14 +217,21 @@ class BackupArchive
try {
$callback($zip);
} catch (\Throwable $e) {
$zip->close();
// La chiusura di cortesia non deve poter sostituire l'errore vero:
// dopo un close() fallito l'oggetto ZipArchive resta inutilizzabile
// e ogni metodo successivo solleva "Invalid or uninitialized Zip
// object", che finirebbe sotto gli occhi dell'amministratore al
// posto del messaggio che spiega davvero cos'e' andato storto.
try {
$zip->close();
} catch (\Throwable) {
// gia' chiuso o invalido: non c'e' niente da salvare
}
throw $e;
}
if (! $zip->close()) {
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
$this->chiudi($zip);
}
/**
@@ -169,15 +241,42 @@ class BackupArchive
*/
private function chiudiEriapri(ZipArchive $zip, string $path): void
{
if (! $zip->close()) {
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
$this->chiudi($zip);
if ($zip->open($path) !== true) {
throw new RuntimeException('Impossibile riaprire il file di backup.');
}
}
/**
* Chiude l'archivio riportando il motivo del fallimento.
*
* E' in close() che libzip legge davvero i file e scrive l'archivio, quindi
* e' qui che si manifestano permessi negati, disco pieno e file spariti. Un
* "scrittura non riuscita" secco lascerebbe l'amministratore senza il pezzo
* che serve — quale file, e perche' — che sta in getStatusString().
*/
private function chiudi(ZipArchive $zip): void
{
$motivo = '';
try {
if ($zip->close()) {
return;
}
$motivo = (string) $zip->getStatusString();
} catch (\Throwable $e) {
$motivo = $e->getMessage();
}
throw new RuntimeException(
'Scrittura del file di backup non riuscita'
. ($motivo !== '' ? ': ' . rtrim($motivo, '. ') . '.' : '.')
. ' Cause tipiche: un file non leggibile nel volume storage, o spazio esaurito.'
);
}
/**
* Apre il pacchetto, ne verifica la struttura ed estrae i file in una
* cartella di staging, restituendo l'XML e il percorso dello staging.
@@ -233,9 +332,17 @@ class BackupArchive
$totalBytes = 0;
$extracted = 0;
// Le entry di storage/ sono tutte tranne manifest e data.xml: e' il
// totale su cui si misura l'estrazione.
$daEstrarre = max(0, $zip->numFiles - 2);
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
// due entry di servizio": un archivio con una entry in piu' (una
// cartella dichiarata, un file aggiunto a mano) sfalserebbe la
// barra fino a farla superare il 100%.
$daEstrarre = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
$daEstrarre++;
}
}
$onProgress && $onProgress(0, $daEstrarre);
for ($index = 0; $index < $zip->numFiles; $index++) {