Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/main/cursor-overlay-settings-store.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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([]);
});
});
7 changes: 3 additions & 4 deletions src/main/cursor-overlay-settings-store.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand All @@ -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;
}
}
Expand Down
81 changes: 81 additions & 0 deletions src/main/json-file-store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
108 changes: 108 additions & 0 deletions src/main/json-file-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await mkdir(dirname(filePath), { recursive: true });
const tempPath = tempPathFor(filePath);
let handle: Awaited<ReturnType<typeof open>> | 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<CorruptJsonBackupResult> {
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'
);
}
143 changes: 141 additions & 2 deletions src/main/pairing/pairing-store.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down
Loading