diff --git a/src/main/cursor-overlay-settings-store.test.ts b/src/main/cursor-overlay-settings-store.test.ts index 43469ac..17e5347 100644 --- a/src/main/cursor-overlay-settings-store.test.ts +++ b/src/main/cursor-overlay-settings-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -70,4 +70,21 @@ describe('JsonCursorOverlaySettingsStore', () => { color: 'blue' }); }); + + it('creates parent directories when saving', () => { + tempDir = mkdtempSync(join(tmpdir(), 'switchify-cursor-overlay-')); + const settingsFile = join(tempDir, 'nested', 'cursor-overlay-settings.json'); + + new JsonCursorOverlaySettingsStore(settingsFile).save(DEFAULT_CURSOR_OVERLAY_SETTINGS); + + expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(DEFAULT_CURSOR_OVERLAY_SETTINGS); + }); + + it('leaves no temp files after saving', () => { + const settingsStore = store(); + + settingsStore.save(DEFAULT_CURSOR_OVERLAY_SETTINGS); + + expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); }); diff --git a/src/main/cursor-overlay-settings-store.ts b/src/main/cursor-overlay-settings-store.ts index ea8e346..39a780d 100644 --- a/src/main/cursor-overlay-settings-store.ts +++ b/src/main/cursor-overlay-settings-store.ts @@ -1,10 +1,10 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { readFileSync } from 'node:fs'; import { DEFAULT_CURSOR_OVERLAY_SETTINGS, normalizeCursorOverlaySettings, type CursorOverlaySettings } from '../shared/cursor-overlay-settings'; +import { writeJsonFileAtomicSync } from './json-file-store'; export class JsonCursorOverlaySettingsStore { constructor(private readonly filePath: string) {} @@ -25,8 +25,7 @@ export class JsonCursorOverlaySettingsStore { save(settings: CursorOverlaySettings): CursorOverlaySettings { const normalized = normalizeCursorOverlaySettings(settings); - mkdirSync(dirname(this.filePath), { recursive: true }); - writeFileSync(this.filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); + writeJsonFileAtomicSync(this.filePath, `${JSON.stringify(normalized, null, 2)}\n`); return normalized; } } diff --git a/src/main/json-file-store.test.ts b/src/main/json-file-store.test.ts new file mode 100644 index 0000000..daa0f4f --- /dev/null +++ b/src/main/json-file-store.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { backupCorruptJsonFile, writeJsonFileAtomic, writeJsonFileAtomicSync } from './json-file-store'; + +describe('json file store helpers', () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('writes async JSON content to the target path', async () => { + const filePath = path('state.json'); + + await writeJsonFileAtomic(filePath, '{ "ok": true }\n'); + + expect(readFileSync(filePath, 'utf8')).toBe('{ "ok": true }\n'); + expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('creates parent directories for async writes', async () => { + const filePath = path('nested', 'state.json'); + + await writeJsonFileAtomic(filePath, '{}\n'); + + expect(readFileSync(filePath, 'utf8')).toBe('{}\n'); + }); + + it('removes async temp files on failure before rename', async () => { + const parentFile = path('not-a-directory'); + writeFileSync(parentFile, 'file', 'utf8'); + + await expect(writeJsonFileAtomic(join(parentFile, 'state.json'), '{}\n')).rejects.toThrow(); + expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('writes sync JSON content to the target path', () => { + const filePath = path('sync-state.json'); + + writeJsonFileAtomicSync(filePath, '{ "ok": true }\n'); + + expect(readFileSync(filePath, 'utf8')).toBe('{ "ok": true }\n'); + expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('creates parent directories for sync writes', () => { + const filePath = path('sync-nested', 'state.json'); + + writeJsonFileAtomicSync(filePath, '{}\n'); + + expect(readFileSync(filePath, 'utf8')).toBe('{}\n'); + }); + + it('backs up corrupt JSON files with a safe filename', async () => { + const filePath = path('pairing-state.json'); + writeFileSync(filePath, '\0\0', 'utf8'); + + const result = await backupCorruptJsonFile(filePath); + + expect(result.backupPath).toMatch(/pairing-state\.corrupt-\d{8}T\d{9}Z\.json$/); + expect(basename(result.backupPath!)).not.toContain(':'); + expect(readFileSync(result.backupPath!, 'utf8')).toBe('\0\0'); + }); + + it('returns null backup path for a missing corrupt file', async () => { + await expect(backupCorruptJsonFile(path('missing.json'))).resolves.toEqual({ backupPath: null }); + }); + + function path(...parts: string[]): string { + if (!tempDir) { + tempDir = mkdtempSync(join(tmpdir(), 'switchify-json-store-')); + } + + return join(tempDir, ...parts); + } +}); diff --git a/src/main/json-file-store.ts b/src/main/json-file-store.ts new file mode 100644 index 0000000..a41b5d5 --- /dev/null +++ b/src/main/json-file-store.ts @@ -0,0 +1,108 @@ +import { constants } from 'node:fs'; +import { mkdir, open, rename, unlink } from 'node:fs/promises'; +import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, extname, join, basename } from 'node:path'; + +export type CorruptJsonBackupResult = { + backupPath: string | null; +}; + +export async function writeJsonFileAtomic(filePath: string, content: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + const tempPath = tempPathFor(filePath); + let handle: Awaited> | null = null; + let renamed = false; + + try { + handle = await open(tempPath, 'w', 0o600); + await handle.writeFile(content, 'utf8'); + await handle.sync(); + await handle.close(); + handle = null; + await rename(tempPath, filePath); + renamed = true; + } finally { + if (handle) { + await handle.close().catch(() => undefined); + } + + if (!renamed) { + await unlink(tempPath).catch(() => undefined); + } + } +} + +export function writeJsonFileAtomicSync(filePath: string, content: string): void { + mkdirSync(dirname(filePath), { recursive: true }); + const tempPath = tempPathFor(filePath); + let fd: number | null = null; + let renamed = false; + + try { + fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o600); + writeFileSync(fd, content, 'utf8'); + fsyncSync(fd); + closeSync(fd); + fd = null; + renameSync(tempPath, filePath); + renamed = true; + } finally { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // Best effort cleanup. + } + } + + if (!renamed) { + try { + unlinkSync(tempPath); + } catch { + // Best effort cleanup. + } + } + } +} + +export async function backupCorruptJsonFile(filePath: string): Promise { + if (!existsSync(filePath)) { + return { backupPath: null }; + } + + const backupPath = corruptBackupPathFor(filePath); + + try { + await rename(filePath, backupPath); + return { backupPath }; + } catch (error) { + if (isMissingFileError(error)) { + return { backupPath: null }; + } + + throw error; + } +} + +function tempPathFor(filePath: string): string { + return `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; +} + +function corruptBackupPathFor(filePath: string, now = new Date()): string { + const extension = extname(filePath); + const name = basename(filePath, extension); + return join(dirname(filePath), `${name}.corrupt-${sanitizeTimestamp(now.toISOString())}${extension}`); +} + +function sanitizeTimestamp(value: string): string { + return value.replace(/[-:.]/g, ''); +} + +function isMissingFileError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ); +} diff --git a/src/main/pairing/pairing-store.test.ts b/src/main/pairing/pairing-store.test.ts index bd13d00..acb3036 100644 --- a/src/main/pairing/pairing-store.test.ts +++ b/src/main/pairing/pairing-store.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest'; -import { removePairedDevice, toPairedDeviceViews, type PairingState } from './pairing-store'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { JsonPairingStore, removePairedDevice, toPairedDeviceViews, type PairingState } from './pairing-store'; describe('toPairedDeviceViews', () => { it('removes shared tokens from paired device metadata', () => { @@ -60,6 +63,142 @@ describe('removePairedDevice', () => { }); }); +describe('JsonPairingStore', () => { + let tempDir: string | null = null; + + afterEach(() => { + vi.restoreAllMocks(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('creates and saves fresh pairing state when the file is missing', async () => { + const filePath = pairingPath(); + + const state = await new JsonPairingStore(filePath).load(); + + expect(state.desktopId).toMatch(/[0-9a-f-]{36}/); + expect(state.pairedDevices).toEqual([]); + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual(state); + }); + + it('loads valid pairing state', async () => { + const filePath = pairingPath(); + writeFileSync(filePath, JSON.stringify(createState()), 'utf8'); + + await expect(new JsonPairingStore(filePath).load()).resolves.toEqual(createState()); + }); + + it('saves formatted JSON and creates parent directories', async () => { + const filePath = nestedPairingPath(); + + await new JsonPairingStore(filePath).save(createState()); + + expect(readFileSync(filePath, 'utf8')).toBe(`${JSON.stringify(createState(), null, 2)}\n`); + }); + + it('leaves no temp files after a successful save', async () => { + const filePath = pairingPath(); + + await new JsonPairingStore(filePath).save(createState()); + + expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('backs up invalid JSON and replaces it with fresh valid state', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const filePath = pairingPath(); + writeFileSync(filePath, '{', 'utf8'); + + const state = await new JsonPairingStore(filePath).load(); + + expect(state.pairedDevices).toEqual([]); + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual(state); + expect(corruptBackups()).toHaveLength(1); + expect(warn.mock.calls.flat().join('\n')).not.toContain('{'); + }); + + it('backs up NUL-byte files and replaces them with fresh valid state', async () => { + const filePath = pairingPath(); + writeFileSync(filePath, '\0'.repeat(562), 'utf8'); + + const state = await new JsonPairingStore(filePath).load(); + + expect(state.desktopId).toMatch(/[0-9a-f-]{36}/); + expect(state.pairedDevices).toEqual([]); + expect(readFileSync(filePath, 'utf8')).toContain('"pairedDevices": []'); + expect(corruptBackups()).toHaveLength(1); + }); + + it('backs up invalid pairing state schema and replaces it with fresh valid state', async () => { + const filePath = pairingPath(); + writeFileSync(filePath, JSON.stringify({ desktopId: 1, pairedDevices: [] }), 'utf8'); + + const state = await new JsonPairingStore(filePath).load(); + + expect(state.pairedDevices).toEqual([]); + expect(corruptBackups()).toHaveLength(1); + }); + + it('backs up invalid paired-device entries and replaces them with fresh valid state', async () => { + const filePath = pairingPath(); + writeFileSync( + filePath, + JSON.stringify({ + desktopId: 'desktop-1', + pairedDevices: [{ deviceId: 'android-1', token: 'secret-token' }] + }), + 'utf8' + ); + + const state = await new JsonPairingStore(filePath).load(); + + expect(state.pairedDevices).toEqual([]); + expect(corruptBackups()).toHaveLength(1); + }); + + it('does not log tokens or corrupt pairing contents during recovery', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const filePath = pairingPath(); + writeFileSync( + filePath, + JSON.stringify({ + desktopId: 'desktop-1', + pairedDevices: [{ deviceId: 'android-1', token: 'secret-token' }] + }), + 'utf8' + ); + + await new JsonPairingStore(filePath).load(); + + const warningText = warn.mock.calls.flat().join('\n'); + expect(warningText).not.toContain('secret-token'); + expect(warningText).not.toContain('android-1'); + }); + + function pairingPath(): string { + if (!tempDir) { + tempDir = mkdtempSync(join(tmpdir(), 'switchify-pairing-store-')); + } + + return join(tempDir, 'pairing-state.json'); + } + + function nestedPairingPath(): string { + if (!tempDir) { + tempDir = mkdtempSync(join(tmpdir(), 'switchify-pairing-store-')); + } + + return join(tempDir, 'nested', 'pairing-state.json'); + } + + function corruptBackups(): string[] { + return readdirSync(tempDir!).filter((name) => name.startsWith('pairing-state.corrupt-')); + } +}); + function createState(): PairingState { return { desktopId: 'desktop-1', diff --git a/src/main/pairing/pairing-store.ts b/src/main/pairing/pairing-store.ts index d28d0a2..b68f3a6 100644 --- a/src/main/pairing/pairing-store.ts +++ b/src/main/pairing/pairing-store.ts @@ -1,7 +1,7 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { readFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import type { PairedDeviceView } from '../../shared/server-status'; +import { backupCorruptJsonFile, writeJsonFileAtomic } from '../json-file-store'; export type PairedDevice = { deviceId: string; @@ -34,13 +34,25 @@ export class JsonPairingStore implements PairingStore { await this.save(state); return state; } + + if (isCorruptPairingStateError(error)) { + const backup = await backupCorruptJsonFile(this.filePath); + console.warn( + backup.backupPath + ? 'Switchify pairing state could not be loaded. The corrupt file was backed up and a fresh pairing state will be used.' + : 'Switchify pairing state could not be loaded. A fresh pairing state will be used.' + ); + const state = createEmptyPairingState(); + await this.save(state); + return state; + } + throw error; } } async save(state: PairingState): Promise { - await mkdir(dirname(this.filePath), { recursive: true }); - await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + await writeJsonFileAtomic(this.filePath, `${JSON.stringify(state, null, 2)}\n`); } } @@ -140,6 +152,13 @@ function isMissingFileError(error: unknown): boolean { return isRecord(error) && error.code === 'ENOENT'; } +function isCorruptPairingStateError(error: unknown): boolean { + return ( + error instanceof SyntaxError || + (error instanceof Error && (error.message === 'Invalid pairing state.' || error.message === 'Invalid paired device.')) + ); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } diff --git a/src/main/pointer-movement-settings-store.test.ts b/src/main/pointer-movement-settings-store.test.ts index 3b5ddcc..ce9afd7 100644 --- a/src/main/pointer-movement-settings-store.test.ts +++ b/src/main/pointer-movement-settings-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -71,6 +71,14 @@ describe('JsonPointerMovementSettingsStore', () => { expect(JSON.parse(readFileSync(settingsFile, 'utf8'))).toEqual(DEFAULT_POINTER_MOVEMENT_SETTINGS); }); + it('leaves no temp files after saving', () => { + const settingsFile = settingsPath(); + + new JsonPointerMovementSettingsStore(settingsFile).save(DEFAULT_POINTER_MOVEMENT_SETTINGS); + + expect(readdirSync(tempDir!).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + function store(): JsonPointerMovementSettingsStore { return new JsonPointerMovementSettingsStore(settingsPath()); } diff --git a/src/main/pointer-movement-settings-store.ts b/src/main/pointer-movement-settings-store.ts index 42c96ac..eba5bd8 100644 --- a/src/main/pointer-movement-settings-store.ts +++ b/src/main/pointer-movement-settings-store.ts @@ -1,10 +1,10 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { readFileSync } from 'node:fs'; import { DEFAULT_POINTER_MOVEMENT_SETTINGS, normalizePointerMovementSettings, type PointerMovementSettings } from '../shared/pointer-movement-settings'; +import { writeJsonFileAtomicSync } from './json-file-store'; export class JsonPointerMovementSettingsStore { constructor(private readonly filePath: string) {} @@ -25,8 +25,7 @@ export class JsonPointerMovementSettingsStore { save(settings: PointerMovementSettings): PointerMovementSettings { const normalized = normalizePointerMovementSettings(settings); - mkdirSync(dirname(this.filePath), { recursive: true }); - writeFileSync(this.filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); + writeJsonFileAtomicSync(this.filePath, `${JSON.stringify(normalized, null, 2)}\n`); return normalized; } }