mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
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:
@@ -43,15 +43,51 @@ export type SyncProgressEvent =
|
||||
}
|
||||
| { type: 'result'; ok: boolean; error: string | null };
|
||||
|
||||
const EVENT_TYPES = new Set([
|
||||
'stage',
|
||||
'merge-summary',
|
||||
'remote-output',
|
||||
'snapshot-created',
|
||||
'check-result',
|
||||
'result',
|
||||
]);
|
||||
const SUMMARY_KEYS: readonly (keyof SyncMergeSummary)[] = [
|
||||
'sessionsMerged',
|
||||
'sessionsAlreadyPresent',
|
||||
'activeSessionsSkipped',
|
||||
'animeAdded',
|
||||
'videosAdded',
|
||||
'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 {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
@@ -62,7 +98,35 @@ export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const type = (parsed as { type?: unknown }).type;
|
||||
if (typeof type !== 'string' || !EVENT_TYPES.has(type)) return null;
|
||||
return parsed as SyncProgressEvent;
|
||||
const event = parsed as Record<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user