diff --git a/src/main/storage.ts b/src/main/storage.ts index a1f197e..89fe9ca 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -944,6 +944,30 @@ function settingsPayload(settings: AppSettings): string { return JSON.stringify(protectPersistedSettings(normalizeSettings(settings)), safeJsonReplacer, 2); } +interface SettingsSavePayloads { + primary: string; + backup: string; +} + +function credentialFreeSettingsPayload(settings: AppSettings): string { + return settingsPayload({ ...settings, rememberToken: false }); +} + +function createSettingsSavePayloads(paths: StoragePaths, settings: AppSettings): SettingsSavePayloads { + const previous = readSettingsFile(paths.configFile); + const backup = previous + ? settingsPayload({ ...previous.settings, rememberToken: settings.rememberToken }) + : credentialFreeSettingsPayload(settings); + return { + primary: settingsPayload(settings), + backup + }; +} + +function captureSettings(settings: AppSettings): AppSettings { + return JSON.parse(JSON.stringify(normalizeSettings(settings))) as AppSettings; +} + function writeSettingsFileAtomically(filePath: string, payload: string): void { const tempPath = `${filePath}.tmp`; try { @@ -1052,45 +1076,42 @@ function readSessionFile(filePath: string): SessionState | null { export function saveSettings(paths: StoragePaths, settings: AppSettings): void { syncSettingsSaveGeneration += 1; ensureBaseDir(paths.baseDir); - const payload = settingsPayload(settings); - if (fs.existsSync(paths.configFile)) { - writeSettingsFileAtomically(`${paths.configFile}.bak`, payload); - } - writeSettingsFileAtomically(paths.configFile, payload); + const payloads = createSettingsSavePayloads(paths, settings); + writeSettingsFileAtomically(`${paths.configFile}.bak`, payloads.backup); + writeSettingsFileAtomically(paths.configFile, payloads.primary); } - -let asyncSettingsSaveRunning = false; -let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null; -let syncSettingsSaveGeneration = 0; - -async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise { + +let asyncSettingsSaveRunning = false; +let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings; generation: number } | null = null; +let syncSettingsSaveGeneration = 0; + +async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings, generation: number): Promise { await fs.promises.mkdir(paths.baseDir, { recursive: true }); + const payloads = createSettingsSavePayloads(paths, settings); const tempPath = `${paths.configFile}.settings.tmp`; - await fsp.writeFile(tempPath, payload, "utf8"); + await fsp.writeFile(tempPath, payloads.primary, "utf8"); if (generation < syncSettingsSaveGeneration) { await fsp.rm(tempPath, { force: true }).catch(() => {}); return; } - if (fs.existsSync(paths.configFile)) { - const backupTempPath = `${paths.configFile}.bak.settings.tmp`; - await fsp.writeFile(backupTempPath, payload, "utf8"); - if (generation < syncSettingsSaveGeneration) { - await Promise.all([ - fsp.rm(tempPath, { force: true }).catch(() => {}), - fsp.rm(backupTempPath, { force: true }).catch(() => {}) - ]); - return; - } - try { - await fsp.rename(backupTempPath, `${paths.configFile}.bak`); - } catch (renameError: unknown) { - if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { - await fsp.copyFile(backupTempPath, `${paths.configFile}.bak`); - await fsp.rm(backupTempPath, { force: true }).catch(() => {}); - } else { - await fsp.rm(backupTempPath, { force: true }).catch(() => {}); - throw renameError; - } + const backupTempPath = `${paths.configFile}.bak.settings.tmp`; + await fsp.writeFile(backupTempPath, payloads.backup, "utf8"); + if (generation < syncSettingsSaveGeneration) { + await Promise.all([ + fsp.rm(tempPath, { force: true }).catch(() => {}), + fsp.rm(backupTempPath, { force: true }).catch(() => {}) + ]); + return; + } + try { + await fsp.rename(backupTempPath, `${paths.configFile}.bak`); + } catch (renameError: unknown) { + if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { + await fsp.copyFile(backupTempPath, `${paths.configFile}.bak`); + await fsp.rm(backupTempPath, { force: true }).catch(() => {}); + } else { + await fsp.rm(backupTempPath, { force: true }).catch(() => {}); + throw renameError; } } try { @@ -1110,30 +1131,29 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string, genera } } -async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise { - if (asyncSettingsSaveRunning) { - asyncSettingsSaveQueued = { paths, payload, generation }; - return; - } - asyncSettingsSaveRunning = true; - try { - await writeSettingsPayload(paths, payload, generation); +async function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettings, generation: number): Promise { + if (asyncSettingsSaveRunning) { + asyncSettingsSaveQueued = { paths, settings, generation }; + return; + } + asyncSettingsSaveRunning = true; + try { + await writeSettingsPayload(paths, settings, generation); } catch (error) { logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`); } finally { asyncSettingsSaveRunning = false; if (asyncSettingsSaveQueued) { - const queued = asyncSettingsSaveQueued; - asyncSettingsSaveQueued = null; - void saveSettingsPayloadAsync(queued.paths, queued.payload, queued.generation); + const queued = asyncSettingsSaveQueued; + asyncSettingsSaveQueued = null; + void saveSettingsPayloadAsync(queued.paths, queued.settings, queued.generation); } } } export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise { const generation = syncSettingsSaveGeneration; - const payload = settingsPayload(settings); - await saveSettingsPayloadAsync(paths, payload, generation); + await saveSettingsPayloadAsync(paths, captureSettings(settings), generation); } export function emptySession(): SessionState { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index d441b57..da74c44 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -11,6 +11,15 @@ import { configureCredentialProtector } from "../src/main/credential-protection" import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; const tempDirs: string[] = []; +type SettingsSaveMode = "sync" | "async"; + +async function saveSettingsInMode(mode: SettingsSaveMode, paths: ReturnType, settings: AppSettings): Promise { + if (mode === "sync") { + saveSettings(paths, settings); + } else { + await saveSettingsAsync(paths, settings); + } +} beforeEach(() => { configureCredentialProtector({ @@ -195,6 +204,32 @@ describe("settings storage", () => { expect(loaded.allDebridToken).toBe("all-token"); }); + it.each(["sync", "async"] as const)("preserves the previous recoverable settings state during a %s save", async (mode) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const previousToken = "previous-value-for-recovery"; + const previous = { + ...defaultSettings(), + packageName: "previous-package-name", + token: previousToken + }; + saveSettings(paths, previous); + + await saveSettingsInMode(mode, paths, { + ...previous, + packageName: "current-package-name", + token: "current-value" + }); + + const backupText = fs.readFileSync(`${paths.configFile}.bak`, "utf8"); + expect(backupText).not.toContain(previousToken); + fs.writeFileSync(paths.configFile, "{broken-current-config", "utf8"); + const recovered = loadSettings(paths); + expect(recovered.packageName).toBe("previous-package-name"); + expect(recovered.token).toBe(previousToken); + }); + it.each(["sync", "async"] as const)("clears previously stored credentials from the backup when remembering is disabled during a %s save", async (mode) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); @@ -202,21 +237,21 @@ describe("settings storage", () => { const remembered = { ...defaultSettings(), rememberToken: true, - token: "stored-value-before-clear" + token: "stored-value-before-clear", + packageName: "previous-package-name" }; saveSettings(paths, remembered); - const cleared = { ...remembered, rememberToken: false }; - if (mode === "sync") { - saveSettings(paths, cleared); - } else { - await saveSettingsAsync(paths, cleared); - } + const cleared = { ...remembered, rememberToken: false, packageName: "current-package-name" }; + await saveSettingsInMode(mode, paths, cleared); const primary = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record; const backup = JSON.parse(fs.readFileSync(`${paths.configFile}.bak`, "utf8")) as Record; expect(primary.token).toBe(""); expect(backup.token).toBe(""); + expect(primary.packageName).toBe("current-package-name"); + expect(backup.packageName).toBe("previous-package-name"); + expect(backup.rememberToken).toBe(false); }); it.each(["sync", "async"] as const)("clears previously stored credentials from the backup when encryption is unavailable during a %s save", async (mode) => { @@ -235,11 +270,7 @@ describe("settings storage", () => { decryptString: () => "" }); - if (mode === "sync") { - saveSettings(paths, remembered); - } else { - await saveSettingsAsync(paths, remembered); - } + await saveSettingsInMode(mode, paths, remembered); const primary = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record; const backup = JSON.parse(fs.readFileSync(`${paths.configFile}.bak`, "utf8")) as Record; @@ -247,6 +278,37 @@ describe("settings storage", () => { expect(backup.token).toBe(""); }); + it.each([ + ["sync", "absent"], + ["async", "absent"], + ["sync", "corrupt"], + ["async", "corrupt"] + ] as const)("uses a credential-free current fallback for a %s save with a %s prior primary", async (mode, priorState) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + if (priorState === "corrupt") { + fs.writeFileSync(paths.configFile, "{broken-prior-config", "utf8"); + } + const currentToken = "current-value-for-safe-fallback"; + + await saveSettingsInMode(mode, paths, { + ...defaultSettings(), + rememberToken: true, + packageName: "current-package-name", + token: currentToken + }); + + expect(fs.existsSync(`${paths.configFile}.bak`)).toBe(true); + const backupText = fs.readFileSync(`${paths.configFile}.bak`, "utf8"); + const backup = JSON.parse(backupText) as Record; + expect(backup.packageName).toBe("current-package-name"); + expect(backup.rememberToken).toBe(false); + expect(backup.token).toBe(""); + expect(backupText).not.toContain(currentToken); + expect(loadSettings(paths).token).toBe(currentToken); + }); + it("migrates remembered plaintext provider values without retaining plaintext in config backups", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir);