From ce72f41bfcda06ae07d36600d0514de6f101716b Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Tue, 5 May 2026 15:58:38 +0930 Subject: [PATCH] fix: backup creation and restore reliability (#527) --- deno.lock | 4 +- docs/api/v1/paths/backups.yaml | 17 +- docs/backend/security.md | 20 +- src/hooks.server.ts | 6 + src/lib/api/v1.d.ts | 20 +- src/lib/api/v1.openapi.json | 3 +- src/lib/server/jobs/logic/createBackup.ts | 102 ++++--- src/lib/server/utils/backup/applyPending.ts | 150 ++++++++++ src/lib/server/utils/backup/sanitize.ts | 155 ++++++++++ src/routes/+layout.server.ts | 14 +- src/routes/+layout.svelte | 46 +++ .../api/v1/backups/[filename]/+server.ts | 24 +- src/routes/settings/backups/+page.server.ts | 77 ++--- src/routes/settings/backups/+page.svelte | 63 ++++- .../backups/specs/backupSecrets.test.ts | 266 ++++++++++-------- .../backups/specs/listAndDownload.test.ts | 15 +- .../integration/backups/specs/restore.test.ts | 21 +- .../backups/specs/restoreFormActions.test.ts | 120 ++++++++ .../backups/specs/settings.test.ts | 2 +- tests/runner.ts | 2 +- tests/unit/backups/applyPending.test.ts | 225 +++++++++++++++ 21 files changed, 1132 insertions(+), 220 deletions(-) create mode 100644 src/lib/server/utils/backup/applyPending.ts create mode 100644 src/lib/server/utils/backup/sanitize.ts create mode 100644 tests/integration/backups/specs/restoreFormActions.test.ts create mode 100644 tests/unit/backups/applyPending.test.ts diff --git a/deno.lock b/deno.lock index b632af72..74108458 100644 --- a/deno.lock +++ b/deno.lock @@ -1503,7 +1503,7 @@ "npm:@deno/vite-plugin@^2.0.2", "npm:@jsr/db__sqlite@0.12", "npm:@playwright/test@^1.59.1", - "npm:@sveltejs/kit@^2.58.0", + "npm:@sveltejs/kit@^2.59.0", "npm:@sveltejs/vite-plugin-svelte@7", "npm:@tailwindcss/vite@^4.2.4", "npm:@types/better-sqlite3@^7.6.13", @@ -1521,7 +1521,7 @@ "npm:prettier-plugin-svelte@^3.5.1", "npm:prettier-plugin-tailwindcss@0.8", "npm:prettier@3.8.3", - "npm:simple-icons@^16.18.0", + "npm:simple-icons@^16.18.1", "npm:svelte-check@^4.4.7", "npm:svelte@^5.55.5", "npm:tailwindcss@^4.1.13", diff --git a/docs/api/v1/paths/backups.yaml b/docs/api/v1/paths/backups.yaml index 756ca6d8..3f43b18c 100644 --- a/docs/api/v1/paths/backups.yaml +++ b/docs/api/v1/paths/backups.yaml @@ -52,6 +52,21 @@ backup_file: get: operationId: downloadBackup summary: Download Backup + description: | + Download a backup archive. The local file on disk is full-fidelity, but + the downloaded copy is sanitized on the fly so it is safer to share. + The following are removed from the downloaded archive: + + - Arr instances (URLs, API keys, sync configs, drift state, rename and + cleanup history) + - Notification services (webhook URLs, tokens, history) + - User accounts and active sessions + - Personal access tokens for linked databases + - AI and TMDB API keys + + The local archive on the server is not modified. Restoring the + downloaded file on a different host will require re-adding the removed + items. tags: - Backups parameters: @@ -63,7 +78,7 @@ backup_file: type: string responses: '200': - description: Backup file download + description: Sanitized backup file download headers: Content-Disposition: schema: diff --git a/docs/backend/security.md b/docs/backend/security.md index 0b6b25db..5d66a5c5 100644 --- a/docs/backend/security.md +++ b/docs/backend/security.md @@ -368,11 +368,21 @@ Secrets are stripped at two levels: - **Frontend responses** - server-side load functions replace sensitive fields with boolean flags (`hasApiKey`, `hasPat`). Webhook URLs are omitted from notification config. Password hashes never leave the server. -- **Backup downloads** - the DB copy inside the archive has all secrets nulled - (`arr_instances.api_key`, `database_instances.personal_access_token`, - `auth_settings.api_key`, `ai_settings.api_key`, `tmdb_settings.api_key`), - notification configs cleared, and auth tables (`users`, `sessions`, - `login_attempts`) emptied. The production database is never touched. +- **Backup downloads** - local backup archives on disk are full-fidelity; the + filesystem is the documented trust boundary, so a backup file sitting next + to the live database does not need to be sanitized. The download endpoint + (`GET /api/v1/backups/{filename}`) instead sanitizes the archive on the fly + before streaming the response. Sanitization deletes whole rows from + `arr_instances` (cascading through arr-side sync, drift, rename, cleanup, + and upgrade tables) and `notification_services` (cascading through + history), and empties `users`, `sessions`, and `login_attempts`. Personal + access tokens on `database_instances`, AI keys on `ai_settings`, and TMDB + keys on `tmdb_settings` are nulled but the rows are kept (PCD repos remain + linked, schedules and other settings preserved). `auth_settings.api_key` + is left in place because it is a bcrypt hash of a high-entropy random key, + computationally infeasible to brute-force from the hash alone. The local + archive on disk and the production database are never modified. See + `src/lib/server/utils/backup/sanitize.ts` for the exact policy. ### XSS via Markdown / {@html} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 74d5de73..925ee126 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -15,6 +15,7 @@ import { logSettings } from '$logger/settings.ts'; import { logger } from '$logger/logger.ts'; import { db } from '$db/db.ts'; import { runMigrations } from '$db/migrations.ts'; +import { applyPendingRestore } from '$utils/backup/applyPending.ts'; import { initializeJobs } from '$jobs/init.ts'; import { recoverInterruptedSyncs } from '$lib/server/sync/utils.ts'; import { pcdManager } from '$pcd/core/manager.ts'; @@ -31,6 +32,11 @@ if (!isReload) { // Initialize configuration on server startup await config.init(); + // Apply any pending restore before opening the DB. This is the only + // safe window: directories exist (config.init mkdir'd them) but no + // SQLite handle is open yet, so we can swap files freely. + await applyPendingRestore(); + // Initialize database await db.initialize(); diff --git a/src/lib/api/v1.d.ts b/src/lib/api/v1.d.ts index 0ba975e9..136f3950 100644 --- a/src/lib/api/v1.d.ts +++ b/src/lib/api/v1.d.ts @@ -250,7 +250,23 @@ export interface paths { path?: never; cookie?: never; }; - /** Download Backup */ + /** + * Download Backup + * @description Download a backup archive. The local file on disk is full-fidelity, but + * the downloaded copy is sanitized on the fly so it is safer to share. + * The following are removed from the downloaded archive: + * + * - Arr instances (URLs, API keys, sync configs, drift state, rename and + * cleanup history) + * - Notification services (webhook URLs, tokens, history) + * - User accounts and active sessions + * - Personal access tokens for linked databases + * - AI and TMDB API keys + * + * The local archive on the server is not modified. Restoring the + * downloaded file on a different host will require re-adding the removed + * items. + */ get: operations['downloadBackup']; put?: never; post?: never; @@ -1522,7 +1538,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Backup file download */ + /** @description Sanitized backup file download */ 200: { headers: { /** @example attachment; filename="backup-2026-03-15-100005.tar.gz" */ diff --git a/src/lib/api/v1.openapi.json b/src/lib/api/v1.openapi.json index 12b3533d..0a52daaf 100644 --- a/src/lib/api/v1.openapi.json +++ b/src/lib/api/v1.openapi.json @@ -1822,6 +1822,7 @@ "get": { "operationId": "downloadBackup", "summary": "Download Backup", + "description": "Download a backup archive. The local file on disk is full-fidelity, but\nthe downloaded copy is sanitized on the fly so it is safer to share.\nThe following are removed from the downloaded archive:\n\n- Arr instances (URLs, API keys, sync configs, drift state, rename and\n cleanup history)\n- Notification services (webhook URLs, tokens, history)\n- User accounts and active sessions\n- Personal access tokens for linked databases\n- AI and TMDB API keys\n\nThe local archive on the server is not modified. Restoring the\ndownloaded file on a different host will require re-adding the removed\nitems.\n", "tags": ["Backups"], "parameters": [ { @@ -1836,7 +1837,7 @@ ], "responses": { "200": { - "description": "Backup file download", + "description": "Sanitized backup file download", "headers": { "Content-Disposition": { "schema": { diff --git a/src/lib/server/jobs/logic/createBackup.ts b/src/lib/server/jobs/logic/createBackup.ts index a007e98f..8db25529 100644 --- a/src/lib/server/jobs/logic/createBackup.ts +++ b/src/lib/server/jobs/logic/createBackup.ts @@ -1,9 +1,21 @@ /** - * Core backup creation logic - * Separated from job definition to avoid database/config dependencies for testing + * Core backup creation logic. + * + * Local backups are full-fidelity: secrets are kept in the on-disk archive + * because it sits inside the same filesystem trust boundary as the source DB. + * Sanitization happens at egress (download endpoint), not at creation. + * + * The SQLite app database is copied via the online backup API rather than a + * raw filesystem `cp`, so the archive contains a single consistent `.db` + * with no `-wal`/`-shm` sidecars. Other contents of the source directory + * (notably `databases/` for cloned PCD repos) are copied as-is. */ import { Database } from '@jsr/db__sqlite'; +import { db } from '$db/db.ts'; +import { migrationRunner } from '$db/migrations.ts'; +import { logger } from '$logger/logger.ts'; +import { build } from '$lib/shared/build.ts'; export interface CreateBackupResult { success: boolean; @@ -12,31 +24,6 @@ export interface CreateBackupResult { error?: string; } -/** - * SQL statements to strip secrets from a backup database copy. - * The production database is never modified — these run against a temp copy only. - */ -const SANITIZE_SQL = [ - "UPDATE arr_instances SET api_key = ''", - 'UPDATE database_instances SET personal_access_token = NULL', - 'UPDATE auth_settings SET api_key = NULL', - "UPDATE ai_settings SET api_key = ''", - "UPDATE tmdb_settings SET api_key = ''", - "UPDATE notification_services SET config = '{}'", - 'DELETE FROM users', - 'DELETE FROM sessions', - 'DELETE FROM login_attempts' -]; - -/** - * Core backup logic - creates a tar.gz archive of a directory - * Pure function that only depends on Deno APIs - * - * @param sourceDir Directory to backup (will backup this entire directory) - * @param backupDir Directory where backup file will be saved - * @param timestamp Optional timestamp for backup filename (defaults to current time) - * @returns Backup result with filename and size or error - */ export async function createBackup( sourceDir: string, backupDir: string, @@ -78,7 +65,8 @@ export async function createBackup( }; } - // Copy source to a temp directory and sanitize the DB copy + // Copy source to a temp directory; the DB inside it gets replaced + // with a clean snapshot below. tmpDir = await Deno.makeTempDir({ prefix: 'profilarr-backup-' }); const tmpDataDir = `${tmpDir}/data`; @@ -96,32 +84,74 @@ export async function createBackup( }; } - // Sanitize the database copy (skip if no DB in source) + // Replace the raw-copied DB (and its sidecars) with a consistent + // snapshot via SQLite's online backup API. The cp above may have + // captured a mid-write `-wal`/`-shm`; a snapshot of committed state + // is the only safe way to copy a live WAL database. Skipped when + // the source has no `profilarr.db` (unit tests with fake source dirs). const dbPath = `${tmpDataDir}/profilarr.db`; + const walPath = `${tmpDataDir}/profilarr.db-wal`; + const shmPath = `${tmpDataDir}/profilarr.db-shm`; try { const dbStat = await Deno.stat(dbPath); if (dbStat.isFile) { - const db = new Database(dbPath); + await Deno.remove(dbPath); + for (const sidecar of [walPath, shmPath]) { + try { + await Deno.remove(sidecar); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + + const dest = new Database(dbPath); try { - for (const sql of SANITIZE_SQL) { - db.exec(sql); // nosemgrep: profilarr.sql.exec-with-variable — SANITIZE_SQL is a hardcoded constant + db.getDatabase().backup(dest); + dest.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + dest.exec('PRAGMA journal_mode = DELETE'); + + // Verify the snapshot. The backup API copies pages as-is, + // so a corrupt source produces a corrupt archive. Surfacing + // it here saves diagnosing mystery errors after restore. + const rows = dest.prepare('PRAGMA integrity_check').all() as Array<{ + integrity_check: string; + }>; + const ok = rows.length === 1 && rows[0].integrity_check === 'ok'; + if (!ok) { + await logger.warn('Backup source has integrity issues; archive contains them as-is', { + source: 'createBackup', + meta: { issues: rows.map((r) => r.integrity_check).slice(0, 10) } + }); } } finally { - db.close(); + dest.close(); } + + // Write INFO.json metadata. Read by the boot-time apply step + // for diagnostics and by the download endpoint for sanitized + // flag handling. Schema version comes from the `migrations` + // table (Profilarr's tracking mechanism), not PRAGMA user_version. + const info = { + appVersion: build.version, + appChannel: build.channel, + schemaVersion: migrationRunner.getCurrentVersion(), + createdAt: now.toISOString(), + sanitized: false + }; + await Deno.writeTextFile(`${tmpDataDir}/INFO.json`, JSON.stringify(info, null, 2)); } } catch (error) { if (error instanceof Deno.errors.NotFound) { - // No database to sanitize — that's fine + // No database in source; nothing to snapshot. } else { return { success: false, - error: `Failed to sanitize backup database: ${error instanceof Error ? error.message : String(error)}` + error: `Failed to snapshot backup database: ${error instanceof Error ? error.message : String(error)}` }; } } - // Create tar.gz archive from the sanitized temp copy + // Create tar.gz archive from the temp copy const command = new Deno.Command('tar', { args: ['-czf', backupPath, '-C', tmpDir, 'data'], stdout: 'piped', diff --git a/src/lib/server/utils/backup/applyPending.ts b/src/lib/server/utils/backup/applyPending.ts new file mode 100644 index 00000000..c88940e0 --- /dev/null +++ b/src/lib/server/utils/backup/applyPending.ts @@ -0,0 +1,150 @@ +/** + * Boot-time restore application. + * + * The restore form action stages a pending restore by writing a sentinel + * file at `${base}/.restore-pending` containing the absolute path of the + * archive to apply. This module's `applyPendingRestore()` runs early in + * the boot sequence, after `config.init()` (so directories exist) but + * before `db.initialize()` (so no SQLite handle is open), and applies + * the staged archive in-place. + * + * Why boot-time, not in-process: SQLite holds memory-mapped state for + * `-wal`/`-shm` while the DB is open. Overwriting `profilarr.db` mid-flight + * leaves the running process reading inconsistent pages. Doing the swap + * during the boot window where no connection exists is the only correct + * pattern (matches Sonarr's `DatabaseRestorationService`). + * + * Failure model: extraction crashes leave the live `data/` untouched + * because we extract into a separate `.restoring/` staging dir before any + * destructive step. Crashes after the wipe but before the swap leave the + * sentinel in place so the next boot retries cleanly. Unexpected errors + * are logged loudly and rethrown so boot fails fast rather than continuing + * into `db.initialize()` against a broken `data/`. + */ + +import { config } from '$config'; +import { logger } from '$logger/logger.ts'; + +const SENTINEL_NAME = '.restore-pending'; +const STAGING_NAME = '.restoring'; + +interface ArchiveInfo { + appVersion?: string; + appChannel?: string; + schemaVersion?: number | null; + createdAt?: string; + sanitized?: boolean; +} + +async function removeIfExists(path: string, opts?: { recursive?: boolean }): Promise { + try { + await Deno.remove(path, opts); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } +} + +async function readArchiveInfo(stagingDataDir: string): Promise { + try { + const raw = await Deno.readTextFile(`${stagingDataDir}/INFO.json`); + return JSON.parse(raw) as ArchiveInfo; + } catch { + // Older archives don't have INFO.json. Not fatal. + return {}; + } +} + +export async function applyPendingRestore(): Promise { + const sentinelPath = `${config.paths.base}/${SENTINEL_NAME}`; + const stagingPath = `${config.paths.base}/${STAGING_NAME}`; + const dataDir = config.paths.data; + + let archivePath: string; + try { + archivePath = (await Deno.readTextFile(sentinelPath)).trim(); + } catch (err) { + if (err instanceof Deno.errors.NotFound) return; + throw err; + } + + // Verify archive still exists. If it's gone, drop the sentinel and + // continue boot with the existing data, destroying nothing. + try { + await Deno.stat(archivePath); + } catch { + await logger.error('Pending restore archive missing; aborting and clearing sentinel', { + source: 'applyPendingRestore', + meta: { archivePath } + }); + await removeIfExists(sentinelPath); + return; + } + + // Wipe any leftover staging dir from a previously crashed apply. + await removeIfExists(stagingPath, { recursive: true }); + await Deno.mkdir(stagingPath, { recursive: true }); + + // Extract into the staging dir. Live `data/` untouched at this point. + const tar = new Deno.Command('tar', { + args: ['-xzf', archivePath, '-C', stagingPath], + stdout: 'piped', + stderr: 'piped' + }); + const tarResult = await tar.output(); + if (tarResult.code !== 0) { + const stderr = new TextDecoder().decode(tarResult.stderr).trim(); + await logger.error('Pending restore extraction failed; live data left untouched', { + source: 'applyPendingRestore', + meta: { archivePath, stderr, exitCode: tarResult.code } + }); + await removeIfExists(stagingPath, { recursive: true }); + // Leave the sentinel in place: the operator may want to fix the + // archive and retry. Failing fast prevents boot against unknown state. + throw new Error(`Restore extraction failed (exit ${tarResult.code}): ${stderr}`); + } + + const stagingDataDir = `${stagingPath}/data`; + + // Sanity-check the archive shape before destroying anything. + try { + const stat = await Deno.stat(`${stagingDataDir}/profilarr.db`); + if (!stat.isFile) throw new Error('profilarr.db is not a file'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await logger.error('Staged archive missing profilarr.db; aborting', { + source: 'applyPendingRestore', + meta: { archivePath, error: message } + }); + await removeIfExists(stagingPath, { recursive: true }); + throw new Error(`Restore archive does not contain data/profilarr.db: ${message}`); + } + + const info = await readArchiveInfo(stagingDataDir); + + // Wipe live data dir contents we own. Anything else (e.g. a file an + // operator manually placed) is left alone. + await removeIfExists(`${dataDir}/profilarr.db`); + await removeIfExists(`${dataDir}/profilarr.db-wal`); + await removeIfExists(`${dataDir}/profilarr.db-shm`); + await removeIfExists(`${dataDir}/databases`, { recursive: true }); + await removeIfExists(`${dataDir}/INFO.json`); + + // Move staged contents into place. Per-entry rename is atomic on the + // same filesystem (which staging and data both are, both under base). + for await (const entry of Deno.readDir(stagingDataDir)) { + const src = `${stagingDataDir}/${entry.name}`; + const dst = `${dataDir}/${entry.name}`; + await removeIfExists(dst, { recursive: true }); + await Deno.rename(src, dst); + } + + // Cleanup. Sentinel last so a crash anywhere above leaves it in place + // for the next boot to retry. + await removeIfExists(stagingPath, { recursive: true }); + await Deno.remove(sentinelPath); + + await logger.info('Pending restore applied', { + source: 'applyPendingRestore', + meta: { archivePath, info } + }); +} diff --git a/src/lib/server/utils/backup/sanitize.ts b/src/lib/server/utils/backup/sanitize.ts new file mode 100644 index 00000000..ade8d618 --- /dev/null +++ b/src/lib/server/utils/backup/sanitize.ts @@ -0,0 +1,155 @@ +/** + * Backup sanitization for the download endpoint. + * + * Local backup archives are full-fidelity (secrets included) because they sit + * inside the same filesystem trust boundary as the source database. The moment + * a backup leaves the host though (download, share, attach to a bug report) + * the trust boundary ends. To make the download path safe to share, we strip + * sensitive data on the fly when the file is requested. + * + * The strategy is "nuke whole rows" rather than "blank specific fields": + * + * - DELETE FROM arr_instances cascades through every arr-side table (sync + * configs, drift, rename, cleanup, upgrades, runs). The user loses URLs + * (which can be internal hostnames), api keys, and the entire sync graph. + * - DELETE FROM notification_services cascades to history. Webhook URLs and + * service names (which can be revealing) go with them. + * - users / sessions / login_attempts removed; usernames could be real + * emails for OIDC users. + * - database_instances PATs are nulled but the rows stay (PCD repos remain + * linked to the user post-restore). + * - ai_settings / tmdb_settings api keys nulled (rows stay). + * - auth_settings.api_key NOT touched; it's bcrypt-hashed of a high-entropy + * random key, so the hash is computationally infeasible to brute-force. + * + * This is more aggressive than a strict per-field strip but it's fail-safe: + * any new sensitive column added later to arr_instances or + * notification_services is automatically protected by virtue of the row + * being deleted. + */ + +import { Database } from '@jsr/db__sqlite'; + +/** + * SQL applied to a backup database copy at download time. Order matters + * only insofar as cascades fire from the parents. + */ +export const SANITIZE_SQL = [ + 'DELETE FROM arr_instances', + 'DELETE FROM notification_services', + 'DELETE FROM users', + 'DELETE FROM sessions', + 'DELETE FROM login_attempts', + 'UPDATE database_instances SET personal_access_token = NULL', + "UPDATE ai_settings SET api_key = ''", + "UPDATE tmdb_settings SET api_key = ''" +]; + +/** + * Categories of data stripped, surfaced to the UI so the user knows exactly + * what they're about to share. Kept in sync with `SANITIZE_SQL` above. + */ +export const SANITIZED_CATEGORIES = [ + 'Arr instances (URLs, API keys, sync configs, drift state, rename and cleanup history)', + 'Notification services (webhook URLs, tokens, history)', + 'User accounts and active sessions', + 'Personal access tokens for linked databases', + 'AI and TMDB API keys' +]; + +/** + * Apply the sanitize SQL to an open SQLite database handle. + * Caller is responsible for opening and closing the handle. + */ +export function applySanitize(db: Database): void { + for (const sql of SANITIZE_SQL) { + db.exec(sql); // nosemgrep: profilarr.sql.exec-with-variable - SANITIZE_SQL is a hardcoded constant + } +} + +interface ArchiveInfo { + appVersion?: string; + appChannel?: string; + schemaVersion?: number | null; + createdAt?: string; + sanitized?: boolean; +} + +/** + * Produce a sanitized copy of a local backup archive. Extracts the archive + * into a temp directory, runs SANITIZE_SQL against the embedded DB, flips + * INFO.json's `sanitized` flag to true, re-tars, and returns the bytes. + * + * The local archive on disk is not modified. + */ +export async function buildSanitizedArchive(archivePath: string): Promise { + const tmpDir = await Deno.makeTempDir({ prefix: 'profilarr-backup-egress-' }); + try { + // Extract. + const extract = new Deno.Command('tar', { + args: ['-xzf', archivePath, '-C', tmpDir], + stdout: 'piped', + stderr: 'piped' + }); + const extractResult = await extract.output(); + if (extractResult.code !== 0) { + const stderr = new TextDecoder().decode(extractResult.stderr).trim(); + throw new Error(`Failed to extract archive: ${stderr}`); + } + + const dataDir = `${tmpDir}/data`; + const dbPath = `${dataDir}/profilarr.db`; + + // Sanitize the DB copy in place. + const dbExists = await Deno.stat(dbPath).then( + () => true, + (err) => { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } + ); + if (dbExists) { + const dest = new Database(dbPath); + try { + applySanitize(dest); + dest.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + dest.exec('PRAGMA journal_mode = DELETE'); + } finally { + dest.close(); + } + } + + // Flip INFO.json's sanitized flag. + const infoPath = `${dataDir}/INFO.json`; + try { + const raw = await Deno.readTextFile(infoPath); + const info = JSON.parse(raw) as ArchiveInfo; + info.sanitized = true; + await Deno.writeTextFile(infoPath, JSON.stringify(info, null, 2)); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + // Older archives may not have INFO.json. Not fatal. + } + + // Re-tar the (now sanitized) data dir. + const archive = `${tmpDir}/sanitized.tar.gz`; + const tar = new Deno.Command('tar', { + args: ['-czf', archive, '-C', tmpDir, 'data'], + stdout: 'piped', + stderr: 'piped' + }); + const tarResult = await tar.output(); + if (tarResult.code !== 0) { + const stderr = new TextDecoder().decode(tarResult.stderr).trim(); + throw new Error(`Failed to re-archive after sanitize: ${stderr}`); + } + + return await Deno.readFile(archive); + } finally { + try { + await Deno.remove(tmpDir, { recursive: true }); + } catch { + // Best-effort cleanup. + } + } +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index d9ea4972..9eb9cd19 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -6,6 +6,17 @@ import { config } from '$lib/server/utils/config/config.ts'; import { build } from '$lib/shared/build.ts'; import { inbox } from '$announcements/index.ts'; +async function readPendingRestore(): Promise<{ filename: string } | null> { + try { + const path = (await Deno.readTextFile(`${config.paths.base}/.restore-pending`)).trim(); + const filename = path.split('/').pop() || path; + return { filename }; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return null; + throw err; + } +} + export const load: LayoutServerLoad = async () => { const arrInstances = arrInstancesQueries.getAll().map((i) => ({ id: i.id, @@ -24,6 +35,7 @@ export const load: LayoutServerLoad = async () => { arrInstances, databases, parserAvailable: await isParserHealthy(), - unreadAnnouncements: inbox.getUnreadCount() + unreadAnnouncements: inbox.getUnreadCount(), + restorePending: await readPendingRestore() }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5b8cec63..bd01c518 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,6 +17,10 @@ import { dev } from '$app/environment'; import { page } from '$app/stores'; import { onMount } from 'svelte'; + import { enhance } from '$app/forms'; + import { alertStore } from '$alerts/store'; + import { AlertTriangle, X } from 'lucide-svelte'; + import Button from '$ui/button/Button.svelte'; export let data; @@ -75,5 +79,47 @@ ? '' : `pt-16 pb-16 md:pt-0 md:pb-0 ${$sidebarCollapsed ? 'md:pl-14' : 'md:pl-80'}`} transition-[padding-left] duration-200 ease-in-out" > + {#if data.restorePending && !isAuthPage} +
+ +
+ Restore pending: + {data.restorePending.filename} + +
+
{ + return async ({ result, update }) => { + if (result.type === 'success') { + alertStore.add('success', 'Pending restore cancelled'); + } else if (result.type === 'failure' && result.data) { + alertStore.add( + 'error', + (result.data as { error?: string }).error || 'Failed to cancel' + ); + } + await update(); + }; + }} + > +
+ {/if} diff --git a/src/routes/api/v1/backups/[filename]/+server.ts b/src/routes/api/v1/backups/[filename]/+server.ts index cb300cfb..6dbb5eaa 100644 --- a/src/routes/api/v1/backups/[filename]/+server.ts +++ b/src/routes/api/v1/backups/[filename]/+server.ts @@ -2,14 +2,20 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; import type { components } from '$api/v1.d.ts'; import { config } from '$config'; +import { logger } from '$logger/logger.ts'; import { isValidBackupFilename, resolveBackupPath } from '$utils/backup/validation.ts'; +import { buildSanitizedArchive } from '$utils/backup/sanitize.ts'; type ErrorResponse = components['schemas']['ErrorResponse']; /** * GET /api/v1/backups/{filename} * - * Download a backup file. + * Download a backup. The local archive on disk is full-fidelity, but the + * downloaded copy is sanitized on the fly before being streamed back: arr + * instances and notification services are deleted (cascading through their + * sync/history tables), users/sessions are wiped, and other secret-bearing + * fields are nulled. See `$utils/backup/sanitize.ts` for the exact policy. */ export const GET: RequestHandler = async ({ params }) => { const { filename } = params; @@ -32,8 +38,20 @@ export const GET: RequestHandler = async ({ params }) => { return json(error, { status: 404 }); } - const file = await Deno.readFile(backupPath); - return new Response(file, { + let bytes: Uint8Array; + try { + bytes = await buildSanitizedArchive(backupPath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await logger.error('Failed to sanitize backup for download', { + source: 'api/v1/backups/download', + meta: { filename, error: message } + }); + const error: ErrorResponse = { error: 'Failed to prepare backup for download' }; + return json(error, { status: 500 }); + } + + return new Response(bytes as BlobPart, { headers: { 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename="${filename}"` diff --git a/src/routes/settings/backups/+page.server.ts b/src/routes/settings/backups/+page.server.ts index 573bd5d5..5839a865 100644 --- a/src/routes/settings/backups/+page.server.ts +++ b/src/routes/settings/backups/+page.server.ts @@ -5,15 +5,20 @@ import { logger } from '$logger/logger.ts'; import { enqueueJob } from '$lib/server/jobs/queueService.ts'; import { buildJobDisplayName } from '$lib/server/jobs/display.ts'; import { listBackups } from '$utils/backup/list.ts'; +import { isValidBackupFilename, resolveBackupPath } from '$utils/backup/validation.ts'; +import { SANITIZED_CATEGORIES } from '$utils/backup/sanitize.ts'; export const load = async () => { const backups = await listBackups(config.paths.backups); - return { backups }; + return { backups, sanitizedCategories: SANITIZED_CATEGORIES }; }; // These actions stay as form actions (session-only, not in the public API): // - cleanupBackups: convenience trigger, already automated via job queue -// - restoreBackup: destructive (overwrites data dir, requires restart) +// - restoreBackup: stages a pending restore; the actual swap happens at +// next boot via applyPendingRestore() before the DB is opened. +// - cancelRestore: clears a staged restore. The banner in the root layout +// posts here from any page, so removing the sentinel is the only effect. // // All other backup operations use /api/v1/backups/* endpoints. export const actions: Actions = { @@ -48,54 +53,62 @@ export const actions: Actions = { const formData = await request.formData(); const filename = formData.get('filename') as string; - if (!filename || !filename.startsWith('backup-') || !filename.endsWith('.tar.gz')) { + if (!filename || !isValidBackupFilename(filename)) { return fail(400, { error: 'Invalid filename' }); } - const backupPath = `${config.paths.backups}/${filename}`; + const backupPath = resolveBackupPath(filename, config.paths.backups); + if (!backupPath) { + return fail(400, { error: 'Invalid filename' }); + } try { // Verify backup exists await Deno.stat(backupPath); - await logger.warn(`Restoring from backup: ${filename}`, { + // Stage a pending restore. The sentinel sits at the base level + // (outside `data/`) so a backup taken while a restore is staged + // won't carry the sentinel into its archive. The actual swap + // happens at next boot via applyPendingRestore(). + const sentinelPath = `${config.paths.base}/.restore-pending`; + await Deno.writeTextFile(sentinelPath, backupPath); + + await logger.warn(`Restore staged: ${filename}`, { source: 'settings/backups', - meta: { filename } - }); - - // Extract backup to base directory (will overwrite data directory) - const command = new Deno.Command('tar', { - args: ['-xzf', backupPath, '-C', config.paths.base], - stdout: 'piped', - stderr: 'piped' - }); - - const { code, stderr } = await command.output(); - - if (code !== 0) { - const errorMessage = new TextDecoder().decode(stderr); - await logger.error('Backup restoration failed', { - source: 'settings/backups', - meta: { filename, error: errorMessage, exitCode: code } - }); - return fail(500, { error: `Restore failed: ${errorMessage}` }); - } - - await logger.info(`Successfully restored from backup: ${filename}`, { - source: 'settings/backups', - meta: { filename } + meta: { filename, archivePath: backupPath } }); return { success: true, - message: 'Backup restored successfully. Please restart the application.' + restartRequired: true, + message: 'Backup staged. Restart Profilarr to apply.' }; } catch (err) { - await logger.error(`Failed to restore backup: ${filename}`, { + await logger.error(`Failed to stage restore: ${filename}`, { source: 'settings/backups', meta: { filename, error: err } }); - return fail(500, { error: 'Failed to restore backup' }); + return fail(500, { error: 'Failed to stage restore' }); + } + }, + + cancelRestore: async () => { + const sentinelPath = `${config.paths.base}/.restore-pending`; + try { + await Deno.remove(sentinelPath); + await logger.info('Pending restore cancelled', { + source: 'settings/backups' + }); + return { success: true, message: 'Pending restore cancelled.' }; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return { success: true, message: 'No pending restore to cancel.' }; + } + await logger.error('Failed to cancel pending restore', { + source: 'settings/backups', + meta: { error: err } + }); + return fail(500, { error: 'Failed to cancel pending restore' }); } } }; diff --git a/src/routes/settings/backups/+page.svelte b/src/routes/settings/backups/+page.svelte index cbd22568..4eb2a30b 100644 --- a/src/routes/settings/backups/+page.svelte +++ b/src/routes/settings/backups/+page.svelte @@ -37,6 +37,7 @@ // Modal state let showDeleteModal = false; let showRestoreModal = false; + let showDownloadModal = false; let selectedBackup: string | null = null; let restoreFormRef: HTMLFormElement | null = null; @@ -44,8 +45,22 @@ let fileInput: HTMLInputElement; let cleanupFormRef: HTMLFormElement; - function downloadBackup(filename: string) { - window.location.href = `/api/v1/backups/${filename}`; + function openDownloadModal(filename: string) { + selectedBackup = filename; + showDownloadModal = true; + } + + function confirmDownload() { + if (selectedBackup) { + window.location.href = `/api/v1/backups/${selectedBackup}`; + } + showDownloadModal = false; + selectedBackup = null; + } + + function cancelDownload() { + showDownloadModal = false; + selectedBackup = null; } function triggerFileUpload() { @@ -252,7 +267,7 @@ icon={Download} size="xs" tooltip="Download" - on:click={() => downloadBackup(row.filename)} + on:click={() => openDownloadModal(row.filename)} />
+ + + +
+

+ The local copy on this server is full-fidelity. The downloaded file is sanitized so it's safer + to share. The following will be removed before download: +

+
    + {#each data.sanitizedCategories as category} +
  • {category}
  • + {/each} +
+

+ Restoring the downloaded file on a different host will require re-adding the removed items. + Linked databases (PCD repos), AI/TMDB settings rows, and your backup schedule are preserved + (with their secret values blanked). +

+ {#if selectedBackup} +

Backup: {selectedBackup}

+ {/if} +
+
diff --git a/tests/integration/backups/specs/backupSecrets.test.ts b/tests/integration/backups/specs/backupSecrets.test.ts index 516eb491..3c04ef19 100644 --- a/tests/integration/backups/specs/backupSecrets.test.ts +++ b/tests/integration/backups/specs/backupSecrets.test.ts @@ -1,38 +1,28 @@ /** - * Integration tests: Backup secret stripping + * Integration tests: Backup download sanitization * - * When a user downloads a backup, the included profilarr.db should NOT - * contain any secrets. This prevents credential leakage if a backup - * file is shared, stored insecurely, or downloaded by a compromised session. + * Local on-disk backup archives are full-fidelity (secrets included). The + * filesystem is the documented trust boundary, so an archive sitting next + * to the live database does not need stripping. The download endpoint + * (GET /api/v1/backups/{filename}) sanitizes on the fly before streaming + * the response, so any backup that leaves the host has secrets removed. * - * The test seeds known secrets into the live database, creates a backup via - * POST /api/v1/backups, polls the job until complete, downloads and extracts - * the tar.gz, and inspects the database copy inside. + * Sanitize policy (see src/lib/server/utils/backup/sanitize.ts): + * - DELETE arr_instances (cascades through arr-side sync, drift, rename, + * cleanup, upgrade tables) + * - DELETE notification_services (cascades to history) + * - DELETE users, sessions, login_attempts + * - NULL database_instances.personal_access_token (rows preserved) + * - Empty ai_settings.api_key, tmdb_settings.api_key + * - auth_settings.api_key intentionally untouched (bcrypt hash of a + * high-entropy random key, computationally safe to share) * - * Secrets that must be stripped: - * - arr_instances.api_key (Radarr/Sonarr API keys) - * - database_instances.personal_access_token (GitHub PATs) - * - auth_settings.api_key (bcrypt-hashed Profilarr API key) - * - ai_settings.api_key (OpenAI/Anthropic keys) - * - tmdb_settings.api_key (TMDB key) - * - notification_services.config (JSON with webhook URLs) - * - users (password hashes) - * - sessions (active session tokens) - * - login_attempts (IP addresses) - * - * Tests: - * 1. Backup DB does not contain arr API keys - * 2. Backup DB does not contain database PATs - * 3. Backup DB does not contain Profilarr API key - * 4. Backup DB does not contain AI API key - * 5. Backup DB does not contain TMDB API key - * 6. Backup DB does not contain notification webhook URLs - * 7. Backup DB does not contain user password hashes - * 8. Backup DB does not contain sessions - * 9. Backup DB does not contain login attempts + * The test seeds known secrets, creates a backup via the v1 API, downloads + * it, and inspects the downloaded copy. It also verifies the on-disk + * archive is bytewise unchanged by the download. */ -import { assertEquals } from '@std/assert'; +import { assert, assertEquals, assertNotEquals } from '@std/assert'; import { TestClient } from '$test-harness/client.ts'; import { startServer, stopServer, getDbPath } from '$test-harness/server.ts'; import { createUserDirect, login } from '$test-harness/setup.ts'; @@ -42,6 +32,7 @@ import { hash } from '@felix/bcrypt'; const PORT = 7017; const ORIGIN = `http://localhost:${PORT}`; +const BACKUPS_DIR = `./dist/integration-${PORT}/backups`; // Known secrets seeded into the live DB const ARR_API_KEY = 'sonarr-backup-test-key-abc123'; @@ -52,7 +43,11 @@ const PROFILARR_API_KEY = 'profilarr-backup-test-key-jkl345'; const WEBHOOK_URL = 'https://discord.com/api/webhooks/backup-test-id/backup-test-token'; let backupDbPath: string; +let infoPath: string; let extractDir: string; +let onDiskBytesBefore: Uint8Array; +let onDiskBytesAfter: Uint8Array; +let liveProfilarrApiKeyHash: string; async function seedSecrets(dbPath: string) { const db = openDb(dbPath); @@ -104,17 +99,26 @@ async function seedSecrets(dbPath: string) { } } +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + /** - * Trigger backup via the v1 API, poll the job until complete, download and - * extract the archive. Returns the path to the extracted profilarr.db. + * Trigger backup via the v1 API, poll the job until complete, capture the + * on-disk bytes, download (which sanitizes on the fly), capture the on-disk + * bytes again to verify they're unchanged, then extract the downloaded copy. */ -async function downloadAndExtractBackup(client: TestClient): Promise { - // Trigger backup creation via API +async function downloadAndExtractBackup( + client: TestClient +): Promise<{ dbPath: string; infoPath: string }> { const createRes = await client.post('/api/v1/backups', {}); assertEquals(createRes.status, 202, 'Backup creation should return 202'); const { jobId } = await createRes.json(); - // Poll job status until complete for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 1000)); const jobRes = await client.get(`/api/v1/jobs/${jobId}`); @@ -123,7 +127,6 @@ async function downloadAndExtractBackup(client: TestClient): Promise { if (job.status === 'failure') throw new Error(`Backup job failed: ${job.result?.error}`); } - // Get the backup filename from the list const listRes = await client.get('/api/v1/backups'); assertEquals(listRes.status, 200, 'Backup list should return 200'); const backups = await listRes.json(); @@ -131,18 +134,24 @@ async function downloadAndExtractBackup(client: TestClient): Promise { throw new Error('No backup files found after job completed'); } const backupFilename = backups[0].filename; + const onDiskArchivePath = `${BACKUPS_DIR}/${backupFilename}`; - // Download the backup + // Capture bytes BEFORE download. + onDiskBytesBefore = await Deno.readFile(onDiskArchivePath); + + // Download triggers sanitize-on-egress. const res = await client.get(`/api/v1/backups/${backupFilename}`); assertEquals(res.status, 200, 'Backup download should return 200'); - // Write to a temp file + // Capture bytes AFTER download. Should match exactly: the on-disk source + // is never mutated. + onDiskBytesAfter = await Deno.readFile(onDiskArchivePath); + const tmpDir = await Deno.makeTempDir({ prefix: 'profilarr-backup-test-' }); const tarPath = `${tmpDir}/backup.tar.gz`; const data = new Uint8Array(await res.arrayBuffer()); await Deno.writeFile(tarPath, data); - // Extract extractDir = `${tmpDir}/extracted`; await Deno.mkdir(extractDir, { recursive: true }); const extract = new Deno.Command('tar', { @@ -155,10 +164,9 @@ async function downloadAndExtractBackup(client: TestClient): Promise { throw new Error('Failed to extract backup tar.gz'); } - // Find profilarr.db in extracted contents const dbPath = `${extractDir}/data/profilarr.db`; await Deno.stat(dbPath); - return dbPath; + return { dbPath, infoPath: `${extractDir}/data/INFO.json` }; } setup(async () => { @@ -166,141 +174,177 @@ setup(async () => { await createUserDirect(getDbPath(PORT), 'admin', 'password123'); await seedSecrets(getDbPath(PORT)); - // Login and download the backup once — all tests inspect the same extracted DB + // Capture the live bcrypt hash so we can later assert it survives the + // sanitize unchanged. + const live = openDb(getDbPath(PORT)); + try { + const row = live.prepare('SELECT api_key FROM auth_settings WHERE id = 1').get() as { + api_key: string; + }; + liveProfilarrApiKeyHash = row.api_key; + } finally { + live.close(); + } + const client = new TestClient(ORIGIN); await login(client, 'admin', 'password123', ORIGIN); - backupDbPath = await downloadAndExtractBackup(client); + const paths = await downloadAndExtractBackup(client); + backupDbPath = paths.dbPath; + infoPath = paths.infoPath; }); teardown(async () => { await stopServer(PORT); if (extractDir) { try { - // extractDir is inside a temp dir — remove the parent const tmpDir = extractDir.replace('/extracted', ''); await Deno.remove(tmpDir, { recursive: true }); } catch { - // Cleanup is best-effort + // best effort } } }); -test('backup DB does not contain arr API keys', () => { +// ─── Row deletion ──────────────────────────────────────────────────────────── + +test('downloaded archive: arr_instances rows are deleted', () => { const db = openDb(backupDbPath); try { - const rows = db.prepare('SELECT api_key FROM arr_instances').all() as { api_key: string }[]; - for (const row of rows) { - assertEquals(row.api_key === ARR_API_KEY, false, 'Backup DB contains plaintext arr API key'); - } + const row = db.prepare('SELECT COUNT(*) as count FROM arr_instances').get() as { + count: number; + }; + assertEquals(row.count, 0, 'arr_instances should be empty in downloaded archive'); } finally { db.close(); } }); -test('backup DB does not contain database PATs', () => { +test('downloaded archive: notification_services rows are deleted', () => { + const db = openDb(backupDbPath); + try { + const row = db.prepare('SELECT COUNT(*) as count FROM notification_services').get() as { + count: number; + }; + assertEquals(row.count, 0, 'notification_services should be empty in downloaded archive'); + } finally { + db.close(); + } +}); + +test('downloaded archive: users are deleted', () => { + const db = openDb(backupDbPath); + try { + const rows = db.prepare('SELECT * FROM users').all(); + assertEquals(rows.length, 0); + } finally { + db.close(); + } +}); + +test('downloaded archive: sessions are deleted', () => { + const db = openDb(backupDbPath); + try { + const rows = db.prepare('SELECT * FROM sessions').all(); + assertEquals(rows.length, 0); + } finally { + db.close(); + } +}); + +test('downloaded archive: login_attempts are deleted', () => { + const db = openDb(backupDbPath); + try { + const rows = db.prepare('SELECT * FROM login_attempts').all(); + assertEquals(rows.length, 0); + } finally { + db.close(); + } +}); + +// ─── Field blanking ────────────────────────────────────────────────────────── + +test('downloaded archive: database_instances rows preserved with PAT nulled', () => { const db = openDb(backupDbPath); try { const rows = db.prepare('SELECT personal_access_token FROM database_instances').all() as { personal_access_token: string | null; }[]; + assert( + rows.length > 0, + 'database_instances rows should be preserved (PCD repos remain linked)' + ); for (const row of rows) { - assertEquals( - row.personal_access_token === DB_PAT, - false, - 'Backup DB contains plaintext database PAT' - ); + assertEquals(row.personal_access_token, null, 'PAT should be NULL in downloaded archive'); } } finally { db.close(); } }); -test('backup DB does not contain Profilarr API key', () => { - const db = openDb(backupDbPath); - try { - const row = db.prepare('SELECT api_key FROM auth_settings WHERE id = 1').get() as { - api_key: string | null; - }; - assertEquals(row.api_key, null, 'Backup DB contains Profilarr API key hash'); - } finally { - db.close(); - } -}); - -test('backup DB does not contain AI API key', () => { +test('downloaded archive: AI api_key is blanked', () => { const db = openDb(backupDbPath); try { const row = db.prepare('SELECT api_key FROM ai_settings WHERE id = 1').get() as - | { - api_key: string | null; - } + | { api_key: string } | undefined; if (row) { - assertEquals(row.api_key === AI_API_KEY, false, 'Backup DB contains plaintext AI API key'); + assertNotEquals(row.api_key, AI_API_KEY, 'AI api_key plaintext should be removed'); } } finally { db.close(); } }); -test('backup DB does not contain TMDB API key', () => { +test('downloaded archive: TMDB api_key is blanked', () => { const db = openDb(backupDbPath); try { const row = db.prepare('SELECT api_key FROM tmdb_settings WHERE id = 1').get() as { api_key: string; }; - assertEquals(row.api_key === TMDB_API_KEY, false, 'Backup DB contains plaintext TMDB API key'); + assertNotEquals(row.api_key, TMDB_API_KEY, 'TMDB api_key plaintext should be removed'); } finally { db.close(); } }); -test('backup DB does not contain notification webhook URLs', () => { +// ─── Intentionally preserved ───────────────────────────────────────────────── + +test('downloaded archive: auth_settings.api_key bcrypt hash is preserved', () => { const db = openDb(backupDbPath); try { - const rows = db.prepare('SELECT config FROM notification_services').all() as { - config: string; - }[]; - for (const row of rows) { - assertEquals( - row.config.includes(WEBHOOK_URL), - false, - 'Backup DB contains webhook URL in notification config' - ); - } + const row = db.prepare('SELECT api_key FROM auth_settings WHERE id = 1').get() as { + api_key: string | null; + }; + assert(row.api_key !== null, 'auth_settings.api_key should retain its bcrypt hash'); + assertEquals( + row.api_key, + liveProfilarrApiKeyHash, + 'hash in archive should match the live DB value' + ); + assertNotEquals( + row.api_key, + PROFILARR_API_KEY, + 'hash should never equal plaintext (sanity check)' + ); } finally { db.close(); } }); -test('backup DB does not contain user password hashes', () => { - const db = openDb(backupDbPath); - try { - const rows = db.prepare('SELECT * FROM users').all(); - assertEquals(rows.length, 0, 'Backup DB still contains user records'); - } finally { - db.close(); - } +// ─── INFO.json + on-disk integrity ─────────────────────────────────────────── + +test('downloaded archive: INFO.json has sanitized=true', async () => { + const raw = await Deno.readTextFile(infoPath); + const info = JSON.parse(raw) as { sanitized?: boolean }; + assertEquals(info.sanitized, true, 'INFO.json.sanitized should be flipped to true on download'); }); -test('backup DB does not contain sessions', () => { - const db = openDb(backupDbPath); - try { - const rows = db.prepare('SELECT * FROM sessions').all(); - assertEquals(rows.length, 0, 'Backup DB still contains session records'); - } finally { - db.close(); - } -}); - -test('backup DB does not contain login attempts', () => { - const db = openDb(backupDbPath); - try { - const rows = db.prepare('SELECT * FROM login_attempts').all(); - assertEquals(rows.length, 0, 'Backup DB still contains login attempt records'); - } finally { - db.close(); - } +test('local on-disk archive is bytewise unchanged after download', () => { + assertEquals( + bytesEqual(onDiskBytesBefore, onDiskBytesAfter), + true, + 'Download must not modify the source archive on disk' + ); }); await run(); diff --git a/tests/integration/backups/specs/listAndDownload.test.ts b/tests/integration/backups/specs/listAndDownload.test.ts index a9b1593f..007855ea 100644 --- a/tests/integration/backups/specs/listAndDownload.test.ts +++ b/tests/integration/backups/specs/listAndDownload.test.ts @@ -19,14 +19,21 @@ let client: TestClient; let unauthClient: TestClient; let apiKeyClient: TestClient; -/** Create a dummy backup file for testing. */ +/** + * Create a dummy backup file for testing. + * + * The download endpoint extracts the archive and re-tars `${tmp}/data` after + * sanitizing, so dummies must contain a `data/` directory at the archive root + * for the download path to succeed. The tests below only check status and + * headers, so we don't need a real DB inside. + */ async function createDummyBackup(filename: string, content: string = 'dummy'): Promise { - // Create a real (tiny) tar.gz so download tests get valid gzip content const tmpDir = await Deno.makeTempDir(); - await Deno.writeTextFile(`${tmpDir}/data.txt`, content); + await Deno.mkdir(`${tmpDir}/data`); + await Deno.writeTextFile(`${tmpDir}/data/data.txt`, content); const cmd = new Deno.Command('tar', { - args: ['-czf', `${BACKUPS_DIR}/${filename}`, '-C', tmpDir, 'data.txt'], + args: ['-czf', `${BACKUPS_DIR}/${filename}`, '-C', tmpDir, 'data'], stdout: 'null', stderr: 'null' }); diff --git a/tests/integration/backups/specs/restore.test.ts b/tests/integration/backups/specs/restore.test.ts index 6bb95748..61389728 100644 --- a/tests/integration/backups/specs/restore.test.ts +++ b/tests/integration/backups/specs/restore.test.ts @@ -53,16 +53,19 @@ function seedData(dbPath: string) { } /** - * Create a backup on server A via the v1 API, poll until complete, - * download it, and return the raw tar.gz bytes. + * Create a backup on server A via the v1 API, poll until complete, and + * return the on-disk archive bytes. + * + * Reads from the filesystem rather than via GET /api/v1/backups/{filename} + * because the download endpoint sanitizes on the fly (deletes arr instances + * etc.) and this test wants to verify a full-fidelity restore. Reading from + * disk simulates an operator copying the file directly off the host. */ -async function createAndDownloadBackup(client: TestClient): Promise { - // Trigger +async function createAndReadBackup(client: TestClient, port: number): Promise { const createRes = await client.post('/api/v1/backups', {}); assertEquals(createRes.status, 202, 'Backup creation should return 202'); const { jobId } = await createRes.json(); - // Poll job until complete for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 1000)); const jobRes = await client.get(`/api/v1/jobs/${jobId}`); @@ -71,15 +74,11 @@ async function createAndDownloadBackup(client: TestClient): Promise if (job.status === 'failure') throw new Error(`Backup job failed: ${job.result?.error}`); } - // Get filename and download const listRes = await client.get('/api/v1/backups'); const backups = await listRes.json(); if (backups.length === 0) throw new Error('No backup files after job completed'); - const res = await client.get(`/api/v1/backups/${backups[0].filename}`); - assertEquals(res.status, 200, 'Backup download should return 200'); - - return new Uint8Array(await res.arrayBuffer()); + return await Deno.readFile(`./dist/integration-${port}/backups/${backups[0].filename}`); } /** @@ -116,7 +115,7 @@ setup(async () => { seedData(getDbPath(PORT_A)); - const archiveBytes = await createAndDownloadBackup(clientA); + const archiveBytes = await createAndReadBackup(clientA, PORT_A); await stopServer(PORT_A); // ── Server B: restore from backup ──────────────────────────────────── diff --git a/tests/integration/backups/specs/restoreFormActions.test.ts b/tests/integration/backups/specs/restoreFormActions.test.ts new file mode 100644 index 00000000..55c4abf1 --- /dev/null +++ b/tests/integration/backups/specs/restoreFormActions.test.ts @@ -0,0 +1,120 @@ +/** + * Integration tests for the restore + cancelRestore form actions on + * `/settings/backups`. Coverage: + * + * - restoreBackup writes `${base}/.restore-pending` containing the resolved + * archive path. + * - cancelRestore removes the sentinel. + * - cancelRestore is idempotent when the sentinel is already absent. + * + * The actions don't require a real backup archive to be a valid SQLite DB; + * they only stat the file before staging. A small dummy tar.gz next to the + * other backups is enough. + */ + +import { assert, assertEquals } from '@std/assert'; +import { TestClient } from '$test-harness/client.ts'; +import { startServer, stopServer } from '$test-harness/server.ts'; +import { createUser, login } from '$test-harness/setup.ts'; +import { setup, teardown, test, run } from '$test-harness/runner.ts'; + +const PORT = 7040; +const ORIGIN = `http://localhost:${PORT}`; +const BASE = `./dist/integration-${PORT}`; +const BACKUPS_DIR = `${BASE}/backups`; +const SENTINEL_PATH = `${BASE}/.restore-pending`; +const BACKUP_FILENAME = 'backup-2099-01-01-000000.tar.gz'; + +let client: TestClient; + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} + +async function createDummyBackup(filename: string): Promise { + const tmpDir = await Deno.makeTempDir(); + await Deno.mkdir(`${tmpDir}/data`); + await Deno.writeTextFile(`${tmpDir}/data/data.txt`, 'dummy'); + const tar = new Deno.Command('tar', { + args: ['-czf', `${BACKUPS_DIR}/${filename}`, '-C', tmpDir, 'data'], + stdout: 'null', + stderr: 'null' + }); + await tar.output(); + await Deno.remove(tmpDir, { recursive: true }); +} + +setup(async () => { + await startServer(PORT, { AUTH: 'on', ORIGIN }, 'preview'); + client = new TestClient(ORIGIN); + await createUser(client, 'admin', 'password123', ORIGIN); + await login(client, 'admin', 'password123', ORIGIN); + await createDummyBackup(BACKUP_FILENAME); +}); + +teardown(async () => { + await stopServer(PORT); +}); + +test('restoreBackup writes a sentinel pointing at the resolved archive path', async () => { + // Make sure we start clean. + if (await exists(SENTINEL_PATH)) await Deno.remove(SENTINEL_PATH); + + const res = await client.postForm( + '/settings/backups?/restoreBackup', + { filename: BACKUP_FILENAME }, + { headers: { Origin: ORIGIN } } + ); + // SvelteKit form actions reply with 200/303 depending on Accept negotiation. + // We don't depend on a specific status; we depend on the side effect. + assert(res.status < 400, `Expected non-error status, got ${res.status}`); + await res.body?.cancel(); + + assertEquals(await exists(SENTINEL_PATH), true, 'sentinel should be written'); + + const contents = (await Deno.readTextFile(SENTINEL_PATH)).trim(); + assert( + contents.endsWith(`/backups/${BACKUP_FILENAME}`), + `sentinel should point at the archive (got ${contents})` + ); +}); + +test('cancelRestore removes the sentinel', async () => { + // Pre-condition: a sentinel exists from the previous test. + if (!(await exists(SENTINEL_PATH))) { + await Deno.writeTextFile(SENTINEL_PATH, `${BACKUPS_DIR}/${BACKUP_FILENAME}`); + } + + const res = await client.postForm( + '/settings/backups?/cancelRestore', + {}, + { headers: { Origin: ORIGIN } } + ); + assert(res.status < 400, `Expected non-error status, got ${res.status}`); + await res.body?.cancel(); + + assertEquals(await exists(SENTINEL_PATH), false, 'sentinel should be removed'); +}); + +test('cancelRestore is idempotent when no sentinel exists', async () => { + // Make sure no sentinel. + if (await exists(SENTINEL_PATH)) await Deno.remove(SENTINEL_PATH); + + const res = await client.postForm( + '/settings/backups?/cancelRestore', + {}, + { headers: { Origin: ORIGIN } } + ); + assert(res.status < 400, `Expected non-error status, got ${res.status}`); + await res.body?.cancel(); + + assertEquals(await exists(SENTINEL_PATH), false); +}); + +await run(); diff --git a/tests/integration/backups/specs/settings.test.ts b/tests/integration/backups/specs/settings.test.ts index b288466f..f1715939 100644 --- a/tests/integration/backups/specs/settings.test.ts +++ b/tests/integration/backups/specs/settings.test.ts @@ -10,7 +10,7 @@ import { startServer, stopServer, getDbPath } from '$test-harness/server.ts'; import { TestClient } from '$test-harness/client.ts'; import { createUser, login, setApiKey } from '$test-harness/setup.ts'; -const PORT = 7034; +const PORT = 7037; const ORIGIN = `http://localhost:${PORT}`; const API_KEY = 'test-api-key-backups-settings-123'; diff --git a/tests/runner.ts b/tests/runner.ts index 3391b689..15fd73c6 100644 --- a/tests/runner.ts +++ b/tests/runner.ts @@ -267,7 +267,7 @@ async function runIntegration(target?: string): Promise { // Determine which suites to run const suitesToRun = suite ? [suite] - : ['auth', 'api', 'conflicts', 'notifications', 'announcements']; + : ['auth', 'api', 'conflicts', 'notifications', 'announcements', 'backups']; // Docker is needed when running auth specs (all or specific ones that need it) const runningAuthSpecs = suitesToRun.includes('auth'); diff --git a/tests/unit/backups/applyPending.test.ts b/tests/unit/backups/applyPending.test.ts new file mode 100644 index 00000000..bd76b011 --- /dev/null +++ b/tests/unit/backups/applyPending.test.ts @@ -0,0 +1,225 @@ +/** + * Unit tests for `applyPendingRestore()` from + * `src/lib/server/utils/backup/applyPending.ts`. + * + * The function is pure filesystem work (extract a tar.gz, swap files, remove + * a sentinel). No DB schema or running server is needed. We isolate state + * per test by pointing `config.setBasePath` at a per-test temp dir. + * + * Branches covered: + * - no sentinel -> noop, no throw + * - sentinel + missing archive -> sentinel removed, live data preserved + * - sentinel + corrupt archive -> throws, sentinel preserved, staging cleaned, + * live data preserved + * - sentinel + archive without `data/profilarr.db` -> throws, sentinel preserved, + * staging cleaned, live data preserved + * - happy path -> sidecars wiped, archive applied, sentinel + staging removed + * - pre-existing `.restoring/` from a crashed previous apply is wiped + * before extract + */ + +import { BaseTest, type TestContext } from '../base/BaseTest.ts'; +import { assertEquals, assertRejects } from '@std/assert'; +import { applyPendingRestore } from '../../../src/lib/server/utils/backup/applyPending.ts'; +import { config } from '../../../src/lib/server/utils/config/config.ts'; + +const SENTINEL_NAME = '.restore-pending'; +const STAGING_NAME = '.restoring'; + +/** + * Build a valid tar.gz at `archivePath` containing `data/profilarr.db` and + * `data/INFO.json`. The "DB" is an arbitrary byte string; applyPendingRestore + * doesn't open it, only stat-checks it as a file. + */ +async function createValidArchive( + workDir: string, + archivePath: string, + dbContents: string +): Promise { + const stage = `${workDir}/_stage`; + await Deno.mkdir(`${stage}/data`, { recursive: true }); + await Deno.writeTextFile(`${stage}/data/profilarr.db`, dbContents); + await Deno.writeTextFile( + `${stage}/data/INFO.json`, + JSON.stringify({ appVersion: 'test', schemaVersion: 0, sanitized: false }) + ); + + const tar = new Deno.Command('tar', { + args: ['-czf', archivePath, '-C', stage, 'data'], + stdout: 'piped', + stderr: 'piped' + }); + const { code } = await tar.output(); + if (code !== 0) throw new Error(`createValidArchive: tar failed with exit ${code}`); + await Deno.remove(stage, { recursive: true }); +} + +/** Build a tar.gz containing only `data/scratch.txt` (no profilarr.db). */ +async function createArchiveWithoutDb(workDir: string, archivePath: string): Promise { + const stage = `${workDir}/_stage`; + await Deno.mkdir(`${stage}/data`, { recursive: true }); + await Deno.writeTextFile(`${stage}/data/scratch.txt`, 'no db here'); + + const tar = new Deno.Command('tar', { + args: ['-czf', archivePath, '-C', stage, 'data'], + stdout: 'piped', + stderr: 'piped' + }); + const { code } = await tar.output(); + if (code !== 0) throw new Error(`createArchiveWithoutDb: tar failed with exit ${code}`); + await Deno.remove(stage, { recursive: true }); +} + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} + +async function readText(path: string): Promise { + return await Deno.readTextFile(path); +} + +class ApplyPendingTest extends BaseTest { + protected override async beforeEach(ctx: TestContext): Promise { + // Point config at the per-test temp dir, then create the data/ + // subdirectory the same way config.init() would. + config.setBasePath(ctx.tempDir); + await Deno.mkdir(`${ctx.tempDir}/data`, { recursive: true }); + } + + testNoSentinel(): void { + this.test('no sentinel: returns without error and does not touch data', async (ctx) => { + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live'); + + await applyPendingRestore(); + + assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live'); + assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false); + assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false); + }); + } + + testMissingArchive(): void { + this.test('sentinel pointing at missing archive: clears sentinel, leaves data', async (ctx) => { + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live'); + await Deno.writeTextFile( + `${ctx.tempDir}/${SENTINEL_NAME}`, + `${ctx.tempDir}/does-not-exist.tar.gz` + ); + + await applyPendingRestore(); + + assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false); + assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live'); + }); + } + + testCorruptArchive(): void { + this.test( + 'sentinel + corrupt archive: throws, sentinel preserved, data preserved', + async (ctx) => { + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live'); + + const archivePath = `${ctx.tempDir}/junk.tar.gz`; + await Deno.writeFile(archivePath, new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff])); + await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath); + + await assertRejects(() => applyPendingRestore(), Error, 'Restore extraction failed'); + + assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), true); + assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false); + assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live'); + } + ); + } + + testArchiveMissingDb(): void { + this.test('sentinel + archive without profilarr.db: throws, data preserved', async (ctx) => { + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live'); + + const archivePath = `${ctx.tempDir}/no-db.tar.gz`; + await createArchiveWithoutDb(ctx.tempDir, archivePath); + await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath); + + await assertRejects(() => applyPendingRestore(), Error, 'does not contain data/profilarr.db'); + + assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), true); + assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false); + assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'live'); + }); + } + + testHappyPath(): void { + this.test( + 'happy path: sidecars wiped, archive applied, sentinel and staging removed', + async (ctx) => { + // Live data: stale DB plus sidecars plus a databases/ subtree. + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live-OLD'); + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db-wal`, 'stale-wal'); + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db-shm`, 'stale-shm'); + await Deno.mkdir(`${ctx.tempDir}/data/databases`); + await Deno.writeTextFile(`${ctx.tempDir}/data/databases/old.txt`, 'gone'); + + const archivePath = `${ctx.tempDir}/backup.tar.gz`; + await createValidArchive(ctx.tempDir, archivePath, 'restored-NEW'); + await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath); + + await applyPendingRestore(); + + // New DB content moved into place. + assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'restored-NEW'); + // Sidecars wiped (archive doesn't include them, and the live ones are removed pre-swap). + assertEquals(await exists(`${ctx.tempDir}/data/profilarr.db-wal`), false); + assertEquals(await exists(`${ctx.tempDir}/data/profilarr.db-shm`), false); + // Old databases/ subtree replaced. + assertEquals(await exists(`${ctx.tempDir}/data/databases/old.txt`), false); + // INFO.json restored. + assertEquals(await exists(`${ctx.tempDir}/data/INFO.json`), true); + // Sentinel removed last; staging dir cleaned up. + assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false); + assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false); + } + ); + } + + testLeftoverStagingWiped(): void { + this.test( + 'pre-existing .restoring/ from a crashed apply is wiped before extract', + async (ctx) => { + await Deno.writeTextFile(`${ctx.tempDir}/data/profilarr.db`, 'live-OLD'); + + // Simulate a crashed previous apply: a stale .restoring/ with junk in it. + await Deno.mkdir(`${ctx.tempDir}/${STAGING_NAME}/data`, { recursive: true }); + await Deno.writeTextFile(`${ctx.tempDir}/${STAGING_NAME}/junk.txt`, 'leftover'); + + const archivePath = `${ctx.tempDir}/backup.tar.gz`; + await createValidArchive(ctx.tempDir, archivePath, 'restored-NEW'); + await Deno.writeTextFile(`${ctx.tempDir}/${SENTINEL_NAME}`, archivePath); + + await applyPendingRestore(); + + // New content applied, leftover gone, staging dir removed entirely. + assertEquals(await readText(`${ctx.tempDir}/data/profilarr.db`), 'restored-NEW'); + assertEquals(await exists(`${ctx.tempDir}/${STAGING_NAME}`), false); + assertEquals(await exists(`${ctx.tempDir}/${SENTINEL_NAME}`), false); + } + ); + } + + runTests(): void { + this.testNoSentinel(); + this.testMissingArchive(); + this.testCorruptArchive(); + this.testArchiveMissingDb(); + this.testHappyPath(); + this.testLeftoverStagingWiped(); + } +} + +const test = new ApplyPendingTest(); +await test.runTests();