import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import type { Profile } from "./profileService"; import { getExportCategories, getExportSuppliers, getExportKeywords, getExportTransactions, getExportImportSources, getExportImportTemplates, serializeToJson, parseImportedJson, type ExportEnvelope, } from "./dataExportService"; // ----------------------------------------------------------------------------- // Pre-migration backup service // // Wrapper around dataExportService + write_export_file / read_import_file that // produces and *verifies* a full SREF backup before the v2 -> v1 categories // migration. Any failure (write, re-read, checksum mismatch) throws with a // structured error code so the UI can map it to an i18n key and abort the // migration cleanly. // // The service intentionally never writes anything to the DB and never mutates // the profile; it only reads data via the existing export helpers and emits a // file. The caller (migration page) is responsible for aborting on throw. // ----------------------------------------------------------------------------- /** Result of a successful pre-migration backup, returned to the caller. */ export interface BackupResult { /** Absolute path to the written SREF file on disk. */ path: string; /** Size of the file on disk in bytes (ciphertext size when encrypted). */ size: number; /** Hex-encoded SHA-256 of the decrypted JSON payload we wrote. */ checksum: string; /** ISO-8601 timestamp at which verification succeeded. */ verifiedAt: string; /** True when the backup is encrypted (profile had a PIN). */ encrypted: boolean; } /** * Minimal profile shape consumed by this service. We accept the full `Profile` * plus an optional clear-text `password` because the PIN hash alone cannot be * used to derive the SREF encryption key — the caller (migration page) is * expected to prompt the user for their PIN right before backup and forward * the verified plaintext here. */ export interface BackupProfileInput { profile: Profile; /** * Clear-text PIN for encrypting the SREF file. Required when * `profile.pin_hash` is set; ignored otherwise. Never logged. */ password?: string; } /** Stable error codes — the UI layer maps these to i18n keys. */ export type BackupErrorCode = | "missing_password" | "documents_dir_unavailable" | "permission_denied" | "disk_space" | "create_dir_failed" | "write_failed" | "read_back_failed" | "verification_mismatch"; export class BackupError extends Error { public readonly code: BackupErrorCode; public readonly detail: string; constructor(code: BackupErrorCode, detail: string) { super(`backup_failed:${code}:${detail}`); this.name = "BackupError"; this.code = code; this.detail = detail; } } // ----------------------------------------------------------------------------- // Filename helpers // ----------------------------------------------------------------------------- /** * Replace filesystem-hostile characters in a profile name so the backup file * can be written on Windows, macOS and Linux without surprises. Empty or * all-stripped names fall back to "profile". */ export function sanitizeProfileName(name: string): string { // Normalize whitespace, strip path separators and characters Windows forbids. // We keep unicode letters/digits, hyphens, dots and underscores. const stripped = name .trim() .replace(/[\\/:*?"<>|\x00-\x1f]/g, "") .replace(/\s+/g, "-") .replace(/\.+$/g, "") .slice(0, 80); return stripped.length > 0 ? stripped : "profile"; } /** * Produce a filesystem-safe ISO-8601 timestamp: `2026-04-19T14-22-05Z`. * Colons are replaced with dashes (Windows forbids `:` in filenames); the * fractional seconds and final `Z` are preserved to keep it sortable. */ export function filesystemSafeIsoTimestamp(date: Date = new Date()): string { return date.toISOString().replace(/:/g, "-").replace(/\.\d+/, ""); } /** * `_avant-migration-.sref` */ export function buildBackupFilename(profileName: string, date: Date = new Date()): string { return `${sanitizeProfileName(profileName)}_avant-migration-${filesystemSafeIsoTimestamp(date)}.sref`; } // ----------------------------------------------------------------------------- // Path joining (platform-agnostic; we only need to append a filename) // ----------------------------------------------------------------------------- function joinPath(dir: string, filename: string): string { // Strip any trailing separator (either style), then reattach with the one // the directory itself appears to be using. Fallback to `/`. const trimmed = dir.replace(/[\\/]+$/, ""); const sep = trimmed.includes("\\") && !trimmed.includes("/") ? "\\" : "/"; return `${trimmed}${sep}${filename}`; } // ----------------------------------------------------------------------------- // SHA-256 via Web Crypto // ----------------------------------------------------------------------------- /** * Compute the hex-encoded SHA-256 digest of a UTF-8 string. Uses the Web * Crypto API exposed by the Tauri WebView (available on Windows, Linux and * macOS in Tauri v2). */ export async function sha256Hex(content: string): Promise { const bytes = new TextEncoder().encode(content); const digest = await crypto.subtle.digest("SHA-256", bytes); const view = new Uint8Array(digest); let hex = ""; for (let i = 0; i < view.length; i++) { hex += view[i].toString(16).padStart(2, "0"); } return hex; } // ----------------------------------------------------------------------------- // Tauri command wrappers // ----------------------------------------------------------------------------- async function ensureBackupDir(): Promise { try { return await invoke("ensure_backup_dir"); } catch (e) { const message = e instanceof Error ? e.message : String(e); if (message.startsWith("permission_denied")) { throw new BackupError("permission_denied", message); } if (message.startsWith("create_dir_failed")) { throw new BackupError("create_dir_failed", message); } throw new BackupError("documents_dir_unavailable", message); } } async function writeBackupFile( filePath: string, content: string, password: string | null, ): Promise { try { await invoke("write_export_file", { filePath, content, password, }); } catch (e) { const message = e instanceof Error ? e.message : String(e); // Tauri's fs error strings surface the underlying io::Error; map the // common ones to stable codes. We deliberately do not echo `password`. const lower = message.toLowerCase(); if (lower.includes("no space") || lower.includes("disk full")) { throw new BackupError("disk_space", message); } if (lower.includes("permission denied")) { throw new BackupError("permission_denied", message); } throw new BackupError("write_failed", message); } } async function readBackupFile(filePath: string, password: string | null): Promise { try { return await invoke("read_import_file", { filePath, password, }); } catch (e) { const message = e instanceof Error ? e.message : String(e); throw new BackupError("read_back_failed", message); } } async function getFileSize(filePath: string): Promise { try { return await invoke("get_file_size", { filePath }); } catch (e) { const message = e instanceof Error ? e.message : String(e); throw new BackupError("read_back_failed", message); } } // ----------------------------------------------------------------------------- // Public entry point // ----------------------------------------------------------------------------- /** * Create and verify a full SREF backup of the currently connected profile, * to be used as a safety net before the v2 -> v1 categories migration. * * Flow: * 1. Gather categories, suppliers, keywords, and transactions via the * existing dataExportService helpers. * 2. Serialize to JSON using the `transactions_with_categories` envelope. * 3. Resolve `~/Documents/Simpl-Resultat/backups/` (create if missing). * 4. Write the file via `write_export_file` (encrypted when a PIN password * is supplied, plaintext otherwise). * 5. Re-read the file via `read_import_file` and validate: * - JSON parses and has a well-formed envelope; * - SHA-256 of the re-read content matches the SHA-256 of what we * just serialized. * 6. Return a {@link BackupResult} or throw a {@link BackupError}. * * On any failure past step 3, the partially-written file may exist on disk — * we deliberately leave it alone. The migration page is responsible for * aborting and surfacing the error to the user; the broken file does not * affect the profile DB. */ export async function createPreMigrationBackup( input: BackupProfileInput, ): Promise { const { profile, password } = input; const isProtected = profile.pin_hash !== null && profile.pin_hash !== ""; const trimmedPassword = password?.trim() ?? ""; if (isProtected && trimmedPassword.length === 0) { throw new BackupError( "missing_password", "Profile is PIN-protected — a password is required to encrypt the backup", ); } // 1. Gather data — same mode as "transactions_with_categories" export. // Import sources and templates are part of that mode since #331: this // backup is a safety net, and one that came back without a single import // configuration would be a poor one. const appVersion = await getVersion(); const [ categories, suppliers, keywords, transactions, import_sources, import_config_templates, ] = await Promise.all([ getExportCategories(), getExportSuppliers(), getExportKeywords(), getExportTransactions(), getExportImportSources(), getExportImportTemplates(), ]); const content = serializeToJson( "transactions_with_categories", { categories, suppliers, keywords, transactions, import_sources, import_config_templates, }, appVersion, ); // 2. Compute the expected checksum before writing. const expectedChecksum = await sha256Hex(content); // 3. Resolve target directory and build the filename. const dir = await ensureBackupDir(); const filename = buildBackupFilename(profile.name); const filePath = joinPath(dir, filename); // 4. Write — encrypted with the PIN when the profile is protected. const passwordArg = isProtected ? trimmedPassword : null; await writeBackupFile(filePath, content, passwordArg); // 5. Read back and verify. const roundTripped = await readBackupFile(filePath, passwordArg); let parsedEnvelope: ExportEnvelope; try { parsedEnvelope = parseImportedJson(roundTripped).envelope; } catch (e) { const message = e instanceof Error ? e.message : String(e); throw new BackupError("verification_mismatch", `envelope_parse: ${message}`); } if (parsedEnvelope.export_type !== "transactions_with_categories") { throw new BackupError( "verification_mismatch", `envelope_type: ${parsedEnvelope.export_type}`, ); } const actualChecksum = await sha256Hex(roundTripped); if (actualChecksum !== expectedChecksum) { throw new BackupError( "verification_mismatch", `checksum_diff: expected=${expectedChecksum} actual=${actualChecksum}`, ); } const size = await getFileSize(filePath); return { path: filePath, size, checksum: actualChecksum, verifiedAt: new Date().toISOString(), encrypted: isProtected, }; }