diff --git a/lib/storage.ts b/lib/storage.ts index 07ecd185..59095c22 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,7 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { @@ -62,6 +62,7 @@ import { resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js"; +import { restoreAccountsFromBackupFile } from "./storage/restore.js"; export type { CooldownReason, @@ -769,58 +770,17 @@ export async function restoreAccountsFromBackup( path: string, options?: { persist?: boolean }, ): Promise { - const backupRoot = getNamedBackupRoot(getStoragePath()); - let resolvedBackupRoot: string; - try { - resolvedBackupRoot = await fs.realpath(backupRoot); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup root does not exist: ${backupRoot}`); - } - throw error; - } - let resolvedBackupPath: string; - try { - resolvedBackupPath = await fs.realpath(path); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup file no longer exists: ${path}`); - } - throw error; - } - const relativePath = relative(resolvedBackupRoot, resolvedBackupPath); - const isInsideBackupRoot = - relativePath.length > 0 && - !relativePath.startsWith("..") && - !isAbsolute(relativePath); - if (!isInsideBackupRoot) { - throw new Error( - `Backup path must stay inside ${resolvedBackupRoot}: ${path}`, - ); - } - - const { normalized } = await (async () => { - try { - return await loadAccountsFromPath(resolvedBackupPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new Error(`Backup file no longer exists: ${path}`); - } - throw error; - } - })(); - if (!normalized || normalized.accounts.length === 0) { - throw new Error( - `Backup does not contain any accounts: ${resolvedBackupPath}`, - ); - } - if (options?.persist !== false) { - await saveAccounts(normalized); - } - return normalized; + return restoreAccountsFromBackupFile( + path, + { + realpath: fs.realpath, + getNamedBackupRoot, + getStoragePath, + loadAccountsFromPath, + saveAccounts, + }, + options, + ); } export async function exportNamedBackup( diff --git a/lib/storage/restore.ts b/lib/storage/restore.ts new file mode 100644 index 00000000..cad7dbaf --- /dev/null +++ b/lib/storage/restore.ts @@ -0,0 +1,71 @@ +import { isAbsolute, relative } from "node:path"; +import type { AccountStorageV3 } from "../storage.js"; + +export interface RestoreAccountsFromBackupDeps { + realpath: typeof import("node:fs").promises.realpath; + getNamedBackupRoot: (storagePath: string) => string; + getStoragePath: () => string; + loadAccountsFromPath: ( + path: string, + ) => Promise<{ normalized: AccountStorageV3 | null }>; + saveAccounts: (storage: AccountStorageV3) => Promise; +} + +export async function restoreAccountsFromBackupFile( + path: string, + deps: RestoreAccountsFromBackupDeps, + options?: { persist?: boolean }, +): Promise { + const backupRoot = deps.getNamedBackupRoot(deps.getStoragePath()); + let resolvedBackupRoot: string; + try { + resolvedBackupRoot = await deps.realpath(backupRoot); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup root does not exist: ${backupRoot}`); + } + throw error; + } + let resolvedBackupPath: string; + try { + resolvedBackupPath = await deps.realpath(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup file no longer exists: ${path}`); + } + throw error; + } + const relativePath = relative(resolvedBackupRoot, resolvedBackupPath); + const isInsideBackupRoot = + relativePath.length > 0 && + !relativePath.startsWith("..") && + !isAbsolute(relativePath); + if (!isInsideBackupRoot) { + throw new Error( + `Backup path must stay inside ${resolvedBackupRoot}: ${path}`, + ); + } + + const { normalized } = await (async () => { + try { + return await deps.loadAccountsFromPath(resolvedBackupPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new Error(`Backup file no longer exists: ${path}`); + } + throw error; + } + })(); + if (!normalized || normalized.accounts.length === 0) { + throw new Error( + `Backup does not contain any accounts: ${resolvedBackupPath}`, + ); + } + if (options?.persist !== false) { + await deps.saveAccounts(normalized); + } + return normalized; +}