import assert from 'assert'; import backups from './backups.js'; import backupFormats from './backupformats.js'; import backupSites from './backupsites.js'; import BoxError from './boxerror.js'; import consumers from 'node:stream/consumers'; import crypto from 'node:crypto'; import debugModule from 'debug'; import safe from 'safetydance'; const debug = debugModule('box:backupintegrity'); async function downloadBackupInfo(backupSite, backup) { const stream = await backupSites.storageApi(backupSite).download(backupSite.config, `${backup.remotePath}.backupinfo`); const buffer = await consumers.buffer(stream); return buffer; } async function verify(backup, backupSite, progressCallback) { assert.strictEqual(typeof backup, 'object'); assert.strictEqual(typeof backupSite, 'object'); assert.strictEqual(typeof progressCallback, 'function'); if (backup === null) return [`${backup.id} is missing in database`]; const stats = { startTime: Date.now(), duration: null }; const [downloadError, backupInfoBuffer] = await safe(downloadBackupInfo(backupSite, backup)); if (downloadError) { const messages = [`Failed to download ${backup.remotePath}.backupinfo: ${downloadError.message}`]; stats.duration = Date.now() - stats.startTime; return { stats, messages }; } const validSignature = crypto.verify(null /* algo */, backupInfoBuffer, backupSite.integrityKeyPair.publicKey, Buffer.from(backup.integrity.signature, 'hex')); progressCallback({ message: `${backup.remotePath}.backupinfo has ${validSignature ? 'valid': 'invalid' } signature`}); const backupInfo = JSON.parse(backupInfoBuffer.toString('utf8')); const integrityMap = new Map(Object.entries(backupInfo)); const [verifyError, verifyMessages] = await safe(backupFormats.api(backupSite.format).verify(backupSite, backup.remotePath, integrityMap, progressCallback)); progressCallback({ message: `Verification of ${backup.remotePath} done` }); const messages = []; if (!validSignature) messages.push(`${backup.remotePath}.backupinfo has invalid signature`); if (verifyError) messages.push(`Failed to verify ${backup.remotePath}: ${verifyError.message}`); if (verifyMessages) messages.push(...verifyMessages); debug(`verified: ${backup.remotePath} ${JSON.stringify(messages, null, 4)}`); stats.duration = Date.now() - stats.startTime; return { stats, messages: messages.slice(0, 50) }; // keep rsync fails to 50 to not overflow db } async function check(backupId, progressCallback) { const backup = await backups.get(backupId); if (!backup) throw new BoxError(BoxError.BAD_FIELD, 'Backup not found'); const backupSite = await backupSites.get(backup.siteId); if (!backupSite) throw new BoxError(BoxError.BAD_FIELD, 'Backup site not found'); const total = backup.dependsOn.length + 1; let completed = 0; const aggregatedStats = { startTime: Date.now(), duration: null }; const aggregatedMessages = []; for (const depId of backup.dependsOn) { const depBackup = await backups.get(depId); progressCallback({ percent: Math.round(completed / total * 100), message: `Verifying ${depBackup.remotePath}` }); const result = await verify(depBackup, backupSite, progressCallback); // { stats, messages } completed++; await backups.setIntegrityResult(depBackup, result.messages.length === 0 ? 'passed' : 'failed', result); if (result.messages.length) aggregatedMessages.push(`Integrity check of dependent backup ${depBackup.remotePath} failed`); } progressCallback({ percent: Math.round(completed / total * 100), message: `Verifying ${backup.remotePath}` }); const result = await verify(backup, backupSite, progressCallback); aggregatedStats.duration = Date.now() - aggregatedStats.startTime; aggregatedMessages.push(...result.messages); const status = aggregatedMessages.length === 0 ? 'passed' : 'failed'; await backups.setIntegrityResult(backup, status, { stats: aggregatedStats, messages: aggregatedMessages }); return status; } export default { check };