fix(sync): harden sync CLI, IPC, and UI paths from CodeRabbit review

- reject option-like tokens as flag values (--snapshot --force wrote a
  file named --force); --flag=-value still works
- PowerShell remote quoting uses single-quoted literals so $() in a
  quoted path cannot expand
- sync-hosts.json written via temp file + rename; a crash mid-write
  truncated it and the reader's corrupt-fallback dropped every host
- cancelled sync child escalates SIGTERM -> SIGKILL after 5s grace
- NDJSON progress events validated field-by-field before casting
- snapshot filenames include milliseconds to avoid same-second overwrite
- syncAutoScheduler.stop() wired into will-quit cleanup
- sync --ui exclusivity also rejects --make-temp/--remove-temp/--json
- document --sync-window in app help; group --make-temp/--remove-temp
  under modes in sync usage
This commit is contained in:
2026-07-12 02:10:04 -07:00
parent 25cca8ce24
commit c9f85473bb
19 changed files with 257 additions and 27 deletions
+12 -1
View File
@@ -351,7 +351,18 @@ export function parseCliPrograms(
const makeTemp = options.makeTemp === true; const makeTemp = options.makeTemp === true;
const removeTemp = typeof options.removeTemp === 'string' ? options.removeTemp.trim() : ''; const removeTemp = typeof options.removeTemp === 'string' ? options.removeTemp.trim() : '';
if (options.ui === true) { if (options.ui === true) {
if (host || snapshot || merge || push || pull || check || options.force === true) { if (
host ||
snapshot ||
merge ||
push ||
pull ||
check ||
makeTemp ||
removeTemp ||
options.json === true ||
options.force === true
) {
throw new Error('Sync --ui cannot be combined with other sync options.'); throw new Error('Sync --ui cannot be combined with other sync options.');
} }
syncUiTriggered = true; syncUiTriggered = true;
+1
View File
@@ -80,6 +80,7 @@ ${B}Jellyfin${R}
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override --jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
${B}Stats sync${R} ${B}Stats sync${R}
--sync-window Open the stats sync window
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R} --sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
${D}run --sync-cli --help for details)${R} ${D}run --sync-cli --help for details)${R}
@@ -62,3 +62,20 @@ test('parseSyncCliTokens mirrors launcher sync validation', () => {
assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error');
assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error'); assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error');
}); });
test('parseSyncCliTokens rejects an option-like token where a value is required', () => {
// Without this guard `--snapshot --force` writes a snapshot to a file literally
// named "--force" and silently drops the flag.
assert.deepEqual(parseSyncCliTokens(['sync', '--snapshot', '--force']), {
kind: 'error',
message: 'Missing value for --snapshot.',
});
assert.deepEqual(parseSyncCliTokens(['sync', '--remove-temp', '--force']), {
kind: 'error',
message: 'Missing value for --remove-temp.',
});
// The `--flag=<value>` form still accepts values that begin with "-".
const parsed = parseSyncCliTokens(['sync', '--snapshot=-weird-name.sqlite']);
assert.equal(parsed.kind, 'run');
assert.equal(parsed.kind === 'run' && parsed.args.syncSnapshotPath, '-weird-name.sqlite');
});
+6 -3
View File
@@ -64,7 +64,10 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
continue; continue;
} }
const value = rest[i + 1]; const value = rest[i + 1];
if (value === undefined) { // An option-like token is never a value: `--snapshot --force` must fail
// loudly instead of writing a snapshot to a file named "--force". Paths
// that really do start with "-" can still be passed as `--snapshot=-x`.
if (value === undefined || value.startsWith('-')) {
return { kind: 'error', message: `Missing value for ${token}.` }; return { kind: 'error', message: `Missing value for ${token}.` };
} }
assignValue(value); assignValue(value);
@@ -144,6 +147,8 @@ export function syncCliUsage(): string {
' <host> Sync stats with an SSH destination (user@host or ssh alias)', ' <host> Sync stats with an SSH destination (user@host or ssh alias)',
' --snapshot <file> Write a consistent snapshot of the local stats database', ' --snapshot <file> Write a consistent snapshot of the local stats database',
' --merge <file> Merge a snapshot database file into the local stats database', ' --merge <file> Merge a snapshot database file into the local stats database',
' --make-temp Create a sync temp directory and print its path (used over SSH)',
' --remove-temp <dir> Remove a sync temp directory created by --make-temp',
'', '',
'Options:', 'Options:',
' --push Only merge local stats into the SSH host', ' --push Only merge local stats into the SSH host',
@@ -153,8 +158,6 @@ export function syncCliUsage(): string {
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host', ' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
' -f, --force Skip the running-app safety check', ' -f, --force Skip the running-app safety check',
' --json Emit machine-readable NDJSON progress output', ' --json Emit machine-readable NDJSON progress output',
' --make-temp Create a sync temp directory and print its path (used over SSH)',
' --remove-temp <dir> Remove a sync temp directory created by --make-temp',
' --log-level <level> Log level', ' --log-level <level> Log level',
' --help Show this help', ' --help Show this help',
' --version Show the SubMiner version', ' --version Show the SubMiner version',
+14
View File
@@ -160,3 +160,17 @@ test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values',
assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/); assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/);
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/); assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
}); });
test('quoteForRemoteShell does not let PowerShell expand a quoted value', () => {
// PowerShell expands $(...) and $var inside double quotes, so a single-quoted
// literal is the only safe form; '' is the escape for an embedded quote.
assert.equal(
quoteForRemoteShell('windows-powershell', 'C:/tmp/$(calc.exe)'),
`'C:/tmp/$(calc.exe)'`,
);
assert.equal(quoteForRemoteShell('windows-powershell', "C:/tmp/it's"), `'C:/tmp/it''s'`);
assert.equal(
quoteForRemoteShell('windows-powershell', 'C:/Users/First Last/Temp/subminer-sync-ab'),
`'C:/Users/First Last/Temp/subminer-sync-ab'`,
);
});
+12 -6
View File
@@ -100,15 +100,21 @@ export function detectRemoteShellFlavor(
} }
/** /**
* Quote one argument for the detected remote shell. Windows shells have no * Quote one argument for the detected remote shell. PowerShell expands $(...)
* safe single-quote escaping (cmd treats ' literally; PowerShell and cmd * and $var inside double quotes, so it gets a single-quoted literal ('' escapes
* disagree on embedded double quotes), so quote-containing values are * a quote). cmd.exe has no single-quote form and treats ' literally, so it keeps
* rejected instead of escaped — sync only ever quotes paths it composed. * double quotes and rejects values carrying a double quote of their own.
*/ */
export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string { export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string {
if (flavor === 'posix') return shellQuote(value); if (flavor === 'posix') return shellQuote(value);
if (value.includes('"') || /[\r\n]/.test(value)) { if (/[\r\n]/.test(value)) {
throw new Error(`Refusing to quote a value with quotes or newlines for a Windows shell: ${value}`); throw new Error(`Refusing to quote a value with newlines for a Windows shell: ${value}`);
}
if (flavor === 'windows-powershell') {
return `'${value.replaceAll("'", "''")}'`;
}
if (value.includes('"')) {
throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`);
} }
return `"${value}"`; return `"${value}"`;
} }
+1
View File
@@ -3951,6 +3951,7 @@ const {
annotationSubtitleWsService.stop(); annotationSubtitleWsService.stop();
}, },
stopTexthookerService: () => texthookerService.stop(), stopTexthookerService: () => texthookerService.stop(),
stopSyncAutoScheduler: () => syncAutoScheduler.stop(),
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
clearWindowsVisibleOverlayForegroundPollLoop(), clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
@@ -16,6 +16,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'), calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -47,7 +48,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
}); });
cleanup(); cleanup();
assert.equal(calls.length, 33); assert.equal(calls.length, 34);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -68,6 +69,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {}, clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {}, destroyMainOverlayWindow: () => {},
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void; clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void; clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void; destroyMainOverlayWindow: () => void;
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts(); deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket(); deps.stopSubtitleWebsocket();
deps.stopTexthookerService(); deps.stopTexthookerService();
deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop(); deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(); deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow(); deps.destroyMainOverlayWindow();
@@ -19,6 +19,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'), calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -113,6 +114,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {}, clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({ getMainOverlayWindow: () => ({
@@ -173,6 +175,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
}, },
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {}, clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null, getMainOverlayWindow: () => null,
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void; clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void; clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null; getMainOverlayWindow: () => DestroyableWindow | null;
@@ -73,6 +74,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
}, },
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(), stopTexthookerService: () => deps.stopTexthookerService(),
stopSyncAutoScheduler: () => deps.stopSyncAutoScheduler(),
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(), deps.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {}, clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null, getMainOverlayWindow: () => null,
+24
View File
@@ -2,6 +2,9 @@ import { spawn as nodeSpawn } from 'node:child_process';
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events'; import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args'; import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args';
/** How long a cancelled sync child gets to exit on SIGTERM before SIGKILL. */
const CANCEL_GRACE_MS = 5000;
export interface SyncLauncherChildLike { export interface SyncLauncherChildLike {
stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null; stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null; stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
@@ -114,14 +117,35 @@ export function runSyncLauncher(options: {
}); });
}); });
// A sync child blocked on an ssh password prompt ignores SIGTERM, so escalate
// to SIGKILL if it is still alive after a grace period. The timer is cleared
// on close so a cancelled run cannot hold the process open.
let killTimer: ReturnType<typeof setTimeout> | null = null;
const clearKillTimer = (): void => {
if (killTimer === null) return;
clearTimeout(killTimer);
killTimer = null;
};
child.on('close', clearKillTimer);
return { return {
cancel: () => { cancel: () => {
if (cancelled) return;
cancelled = true; cancelled = true;
try { try {
child.kill('SIGTERM'); child.kill('SIGTERM');
} catch { } catch {
// process may already be gone // process may already be gone
} }
killTimer = setTimeout(() => {
killTimer = null;
try {
child.kill('SIGKILL');
} catch {
// process may already be gone
}
}, CANCEL_GRACE_MS);
killTimer.unref?.();
}, },
done, done,
}; };
+1 -1
View File
@@ -21,7 +21,7 @@ function makeTestRig(root: string) {
const windowStub = { const windowStub = {
isDestroyed: () => false, isDestroyed: () => false,
webContents: { webContents: {
send: (channel: string, payload: unknown) => sent.push({ channel, payload }), send: (channel: string, payload?: unknown) => sent.push({ channel, payload }),
}, },
}; };
+4 -2
View File
@@ -58,9 +58,11 @@ interface ActiveRun {
resultSeen: boolean; resultSeen: boolean;
} }
function formatSnapshotName(nowMs: number): string { // Milliseconds are part of the stamp so two snapshots taken in the same second
// do not land on the same path and silently overwrite each other.
export function formatSnapshotName(nowMs: number): string {
const iso = new Date(nowMs).toISOString(); const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 19).replace(/[-:]/g, '').replace('T', '-'); const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`; return `immersion-${stamp}.sqlite`;
} }
+38 -1
View File
@@ -10,7 +10,22 @@ test('parseSyncProgressLine parses known event types', () => {
const summaryLine = JSON.stringify({ const summaryLine = JSON.stringify({
type: 'merge-summary', type: 'merge-summary',
target: 'local', target: 'local',
summary: { sessionsMerged: 2 }, summary: {
sessionsMerged: 2,
sessionsAlreadyPresent: 0,
activeSessionsSkipped: 0,
animeAdded: 0,
videosAdded: 0,
wordsAdded: 0,
kanjiAdded: 0,
subtitleLinesAdded: 0,
telemetryRowsAdded: 0,
eventsAdded: 0,
excludedWordsAdded: 0,
dailyRollupsCopied: 0,
monthlyRollupsCopied: 0,
rollupGroupsRecomputed: 0,
},
}); });
const parsed = parseSyncProgressLine(summaryLine); const parsed = parseSyncProgressLine(summaryLine);
assert.equal(parsed?.type, 'merge-summary'); assert.equal(parsed?.type, 'merge-summary');
@@ -28,3 +43,25 @@ test('parseSyncProgressLine rejects non-events and garbage', () => {
assert.equal(parseSyncProgressLine(''), null); assert.equal(parseSyncProgressLine(''), null);
assert.equal(parseSyncProgressLine('42'), null); assert.equal(parseSyncProgressLine('42'), null);
}); });
test('parseSyncProgressLine rejects events missing required fields', () => {
// A half-formed event used to be cast straight through; the UI reads
// `.ok` / `.summary` directly and would misreport a failed sync as done.
assert.equal(parseSyncProgressLine('{"type":"result"}'), null);
assert.equal(parseSyncProgressLine('{"type":"result","ok":"yes","error":null}'), null);
assert.equal(parseSyncProgressLine('{"type":"merge-summary","target":"local"}'), null);
assert.equal(
parseSyncProgressLine('{"type":"merge-summary","target":"nowhere","summary":{}}'),
null,
);
assert.equal(parseSyncProgressLine('{"type":"stage","stage":"bogus","message":"x"}'), null);
assert.equal(parseSyncProgressLine('{"type":"snapshot-created"}'), null);
assert.equal(parseSyncProgressLine('{"type":"check-result","host":"h","sshOk":true}'), null);
// Well-formed events still parse.
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
type: 'result',
ok: true,
error: null,
});
});
+75 -11
View File
@@ -43,15 +43,51 @@ export type SyncProgressEvent =
} }
| { type: 'result'; ok: boolean; error: string | null }; | { type: 'result'; ok: boolean; error: string | null };
const EVENT_TYPES = new Set([ const SUMMARY_KEYS: readonly (keyof SyncMergeSummary)[] = [
'stage', 'sessionsMerged',
'merge-summary', 'sessionsAlreadyPresent',
'remote-output', 'activeSessionsSkipped',
'snapshot-created', 'animeAdded',
'check-result', 'videosAdded',
'result', 'wordsAdded',
]); 'kanjiAdded',
'subtitleLinesAdded',
'telemetryRowsAdded',
'eventsAdded',
'excludedWordsAdded',
'dailyRollupsCopied',
'monthlyRollupsCopied',
'rollupGroupsRecomputed',
];
const STAGES: readonly SyncStage[] = [
'snapshot-local',
'snapshot-remote',
'download',
'upload',
'merge-local',
'merge-remote',
];
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function isMergeSummary(value: unknown): value is SyncMergeSummary {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return SUMMARY_KEYS.every((key) => typeof record[key] === 'number');
}
/**
* The events cross a process boundary (the sync child's stdout), so each variant
* is validated field-by-field: a half-formed `result` or `merge-summary` would
* otherwise be cast straight into the UI, which reads `.ok`/`.summary` directly.
*/
export function parseSyncProgressLine(line: string): SyncProgressEvent | null { export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed.startsWith('{')) return null; if (!trimmed.startsWith('{')) return null;
@@ -62,7 +98,35 @@ export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
return null; return null;
} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const type = (parsed as { type?: unknown }).type; const event = parsed as Record<string, unknown>;
if (typeof type !== 'string' || !EVENT_TYPES.has(type)) return null;
return parsed as SyncProgressEvent; switch (event.type) {
case 'stage':
return STAGES.includes(event.stage as SyncStage) && isString(event.message)
? (parsed as SyncProgressEvent)
: null;
case 'merge-summary':
return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary)
? (parsed as SyncProgressEvent)
: null;
case 'remote-output':
return isString(event.text) ? (parsed as SyncProgressEvent) : null;
case 'snapshot-created':
return isString(event.path) ? (parsed as SyncProgressEvent) : null;
case 'check-result':
return isString(event.host) &&
typeof event.sshOk === 'boolean' &&
typeof event.ok === 'boolean' &&
isNullableString(event.remoteCommand) &&
isNullableString(event.remoteVersion) &&
isNullableString(event.error)
? (parsed as SyncProgressEvent)
: null;
case 'result':
return typeof event.ok === 'boolean' && isNullableString(event.error)
? (parsed as SyncProgressEvent)
: null;
default:
return null;
}
} }
+22
View File
@@ -180,3 +180,25 @@ test('writeSyncHostsState creates parent directories', () => {
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState()); assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
}); });
}); });
test('writeSyncHostsState leaves the previous file intact when the write fails', () => {
withTempDir((root) => {
const filePath = getSyncHostsPath(root);
let state = createDefaultSyncHostsState();
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
writeSyncHostsState(filePath, state);
// A crash mid-write must not truncate the live file: readSyncHostsState
// falls back to defaults on a corrupt file, which would drop every host.
let next = upsertSyncHost(state, { host: 'user@desktop' }, 43);
assert.throws(() =>
writeSyncHostsState(filePath, next, {
renameSync: () => {
throw new Error('disk full');
},
}),
);
assert.deepEqual(readSyncHostsState(filePath), state);
assert.equal(fs.existsSync(`${filePath}.tmp`), false);
});
});
+19 -1
View File
@@ -180,16 +180,34 @@ export function readSyncHostsState(
} }
} }
// Written via a sibling temp file + rename so an interrupted write cannot leave
// a truncated sync-hosts.json behind (readSyncHostsState would silently fall
// back to defaults, dropping every configured host).
export function writeSyncHostsState( export function writeSyncHostsState(
filePath: string, filePath: string,
state: SyncHostsState, state: SyncHostsState,
deps?: { deps?: {
mkdirSync?: (candidate: string, options: { recursive: true }) => void; mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
renameSync?: (from: string, to: string) => void;
rmSync?: (target: string, options: { force: true }) => void;
}, },
): void { ): void {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
const renameSync = deps?.renameSync ?? fs.renameSync;
const rmSync = deps?.rmSync ?? fs.rmSync;
mkdirSync(path.dirname(filePath), { recursive: true }); mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); const tempPath = `${filePath}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
try {
renameSync(tempPath, filePath);
} catch (error) {
try {
rmSync(tempPath, { force: true });
} catch {
// best effort: the temp file is disposable
}
throw error;
}
} }