diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index e45aa94..ae99b0c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -232,6 +232,11 @@ class BackupEngine $this->log('Archive created: ' . $sizeHuman); $this->log('SHA-256: ' . ($checksum ?: 'N/A')); + // Verify archive integrity + $this->log('Verifying archive integrity...'); + $this->verifyArchive($archivePath, $profile->backup_type); + $this->log('Archive integrity verified'); + // Step 2.5: Wrap with MokoRestore script (if enabled) $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); @@ -518,6 +523,90 @@ class BackupEngine $zip->close(); } + /** + * Verify that a backup archive can be opened and contains expected entries. + * + * @param string $archivePath Absolute path to the archive file + * @param string $backupType Backup type: full, database, files, differential + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyArchive(string $archivePath, string $backupType): void + { + if (!is_file($archivePath)) { + throw new \RuntimeException('Archive file does not exist: ' . $archivePath); + } + + $extension = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION)); + + // Detect tar.gz (pathinfo only returns 'gz') + if ($extension === 'gz' && str_ends_with(strtolower($archivePath), '.tar.gz')) { + $this->verifyTarGzArchive($archivePath); + + return; + } + + // ZIP verification + $zip = new \ZipArchive(); + + if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) { + throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file'); + } + + if ($zip->numFiles < 1) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: archive contains no files'); + } + + // Verify database.sql exists when backup includes database + if ($backupType !== 'files') { + if ($zip->locateName('database.sql') === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive'); + } + } + + // Spot-check: verify the first entry is readable + $firstName = $zip->getNameIndex(0); + + if ($firstName === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: cannot read first entry'); + } + + $zip->close(); + } + + /** + * Verify a tar.gz archive can be opened and iterated. + * + * @param string $archivePath Absolute path to the .tar.gz file + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyTarGzArchive(string $archivePath): void + { + try { + $phar = new \PharData($archivePath); + $count = 0; + + foreach ($phar as $entry) { + // Spot-check: verify at least the first entry is accessible + $entry->getFilename(); + $count++; + break; + } + + if ($count === 0) { + throw new \RuntimeException('Archive integrity check failed: tar.gz archive contains no entries'); + } + } catch (\RuntimeException $e) { + throw $e; + } catch (\Throwable $e) { + throw new \RuntimeException('Archive integrity check failed: ' . $e->getMessage()); + } + } + /** * Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index e6a0f36..de44718 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -347,6 +347,11 @@ class SteppedBackupEngine $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; + // Verify archive integrity + $session->log('Verifying archive integrity...'); + $this->verifyArchive($session->archivePath, $session->backupType); + $session->log('Archive integrity verified'); + // MokoRestore wrapper if ($session->includeMokoRestore) { $session->log('Wrapping with MokoRestore script...'); @@ -449,6 +454,50 @@ class SteppedBackupEngine $this->completeRecord($session, $uploadFailed); } + /** + * Verify that a backup archive can be opened and contains expected entries. + * + * @param string $archivePath Absolute path to the archive file + * @param string $backupType Backup type: full, database, files, differential + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyArchive(string $archivePath, string $backupType): void + { + if (!is_file($archivePath)) { + throw new \RuntimeException('Archive file does not exist: ' . $archivePath); + } + + $zip = new \ZipArchive(); + + if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) { + throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file'); + } + + if ($zip->numFiles < 1) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: archive contains no files'); + } + + // Verify database.sql exists when backup includes database + if ($backupType !== 'files') { + if ($zip->locateName('database.sql') === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive'); + } + } + + // Spot-check: verify the first entry is readable + $firstName = $zip->getNameIndex(0); + + if ($firstName === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: cannot read first entry'); + } + + $zip->close(); + } + /** * Mark the backup record as complete. */