super-productivity/electron/local-file-sync.ts
Johannes Millan b3f7be0afc fix(electron): guard sync-folder cache against stale-load clobber
If a folder pick (setSyncFolderPath) completes while the very first
getSyncFolderPath disk read is still in flight, the load's resolution
callback would overwrite the freshly-set cache with the now-stale disk
value (null), leaving sync wrongly not-ready until restart. Only seed
the cache from disk when it's still unset; the freshly-picked value wins.
2026-06-10 20:35:00 +02:00

440 lines
15 KiB
TypeScript

import { IPC } from './shared-with-frontend/ipc-events.const';
import { SimpleStoreKey } from './shared-with-frontend/simple-store.const';
import { randomBytes } from 'crypto';
import {
readdirSync,
readFileSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
unlinkSync,
} from 'fs';
import * as path from 'path';
import { error, log } from 'electron-log/main';
import { app, dialog, ipcMain } from 'electron';
import { getWin } from './main-window';
import { resolveSyncPath, type ResolvedSyncPath } from './sync-path-resolver';
import { loadSimpleStoreAll, saveSimpleStore } from './simple-store';
import { assertPathOutside } from './file-path-guard';
import { getImageDataUrl, importImage } from './image-cache';
// SECURITY: file-sync must never read/write/list inside the app's private dir,
// which holds settings/grants/db — touching it is a privilege-escalation
// primitive (e.g. forging the nodeExecution grant file). The path is
// renderer-supplied (untrusted plugin/XSS). Lazy getter — userData can be
// changed at startup (--user-data-dir, Snap). See file-path-guard.ts.
const getAppPrivateDir = (): string => app.getPath('userData');
// Main-owned sync folder path. Backed by simple-store under
// SimpleStoreKey.SYNC_FOLDER_PATH; cached in-memory after first load so each FS
// IPC doesn't re-read the file. The canonical form is stored so subsequent
// realpath comparisons in resolveSyncPath line up regardless of symlink/case-
// fold drift.
let _cachedSyncFolder: string | null | undefined = undefined;
let _loadPromise: Promise<string | null> | null = null;
const _loadSyncFolderFromDisk = async (): Promise<string | null> => {
const all = await loadSimpleStoreAll();
const raw = all[SimpleStoreKey.SYNC_FOLDER_PATH];
return typeof raw === 'string' && raw.length > 0 ? raw : null;
};
const getSyncFolderPath = async (): Promise<string | null> => {
if (_cachedSyncFolder !== undefined) return _cachedSyncFolder;
if (!_loadPromise) {
_loadPromise = _loadSyncFolderFromDisk().then((v) => {
// A pick (setSyncFolderPath) can complete while this first disk read is
// still in flight. Only seed the cache if it's still unset, so the now-
// stale disk value can't clobber the freshly-picked folder. The guard +
// assignment run in one synchronous tick, so setSyncFolderPath cannot
// interleave between them.
if (_cachedSyncFolder === undefined) {
_cachedSyncFolder = v;
}
return _cachedSyncFolder;
});
}
return _loadPromise;
};
const setSyncFolderPath = async (rawPath: string): Promise<string> => {
// Canonicalize at write time so the persisted value is the form
// resolveSyncPath will compare against, and so a relative or
// symlinked path is rejected before it gets stored.
const canonical = realpathSync.native(rawPath);
// Reject a folder equal to or inside userData BEFORE persisting. Otherwise
// we'd store a "configured" folder that resolveSyncPath then denies on every
// sync op — safe but confusing. Throwing here funnels into the handler's
// createSafeIpcError path; the store and the in-memory cache stay untouched.
assertPathOutside(getAppPrivateDir(), canonical);
await saveSimpleStore(SimpleStoreKey.SYNC_FOLDER_PATH, canonical);
_cachedSyncFolder = canonical;
return canonical;
};
// Same `(maybeRoot, relative, userData) → absolutePath` resolution wrapped
// so the five handlers below don't repeat the same incantation. Returns the
// absolute path on success; throws PathNotAllowedError on rejection (the
// existing handler try/catch funnels both into a safe IPC error).
const _resolveRelative = async (
relativePath: string | undefined,
): Promise<ResolvedSyncPath> =>
resolveSyncPath(
(await getSyncFolderPath()) ?? undefined,
relativePath ?? '',
getAppPrivateDir(),
);
const _pathNotAllowed = (): Error => {
const e = new Error('Path not allowed for the sync folder');
e.name = 'PathNotAllowedError';
delete e.stack;
return e;
};
const _isEnoent = (e: unknown): boolean =>
typeof e === 'object' &&
e !== null &&
'code' in e &&
(e as { code?: unknown }).code === 'ENOENT';
const _assertSaveTargetIsFilePath = (resolved: ResolvedSyncPath): void => {
if (resolved.isRoot) {
throw _pathNotAllowed();
}
try {
if (statSync(resolved.absolutePath).isDirectory()) {
throw _pathNotAllowed();
}
} catch (e) {
if (!_isEnoent(e)) {
throw e;
}
}
};
const _resolveTempPathForSave = (resolved: ResolvedSyncPath): string => {
const tempName = [
`.${path.basename(resolved.absolutePath)}`,
process.pid,
Date.now(),
randomBytes(8).toString('hex'),
'tmp',
].join('.');
const candidate = path.join(path.dirname(resolved.absolutePath), tempName);
const tempRelative = path.relative(resolved.root, candidate);
return resolveSyncPath(resolved.root, tempRelative, getAppPrivateDir()).absolutePath;
};
export const initLocalFileSyncAdapter = (): void => {
ipcMain.handle(
IPC.FILE_SYNC_SAVE,
async (
ev,
{
relativePath,
dataStr,
localRev,
}: {
relativePath: string;
dataStr: string;
localRev: string | null;
},
): Promise<string | Error> => {
let tempPath: string | null = null;
try {
const resolved = await _resolveRelative(relativePath);
_assertSaveTargetIsFilePath(resolved);
const filePath = resolved.absolutePath;
log(IPC.FILE_SYNC_SAVE, {
dataLength: dataStr.length,
hasData: dataStr.length > 0,
});
// Atomic write: write to temp file first, then rename.
// renameSync is atomic on ext4/APFS/NTFS, so a crash mid-write
// won't corrupt the original file.
tempPath = _resolveTempPathForSave(resolved);
writeFileSync(tempPath, dataStr, { encoding: 'utf8', flag: 'wx' });
renameSync(tempPath, filePath);
tempPath = null;
return getRev(filePath);
} catch (e) {
if (tempPath) {
try {
unlinkSync(tempPath);
} catch {
// Best-effort cleanup; the safe IPC error below is the important part.
}
}
error('Local file sync save failed', getSafeErrorMeta(e));
return createSafeIpcError(IPC.FILE_SYNC_SAVE, e);
}
},
);
ipcMain.handle(
IPC.FILE_SYNC_LOAD,
async (
ev,
{
relativePath,
localRev,
}: {
relativePath: string;
localRev: string | null;
},
): Promise<{ rev: string; dataStr: string | undefined } | Error> => {
try {
const filePath = (await _resolveRelative(relativePath)).absolutePath;
log(IPC.FILE_SYNC_LOAD, {
hasLocalRev: !!localRev,
});
const dataStr = readFileSync(filePath, { encoding: 'utf-8' });
log('Local file sync load completed', {
dataLength: dataStr.length,
});
return {
rev: getRev(filePath),
dataStr,
};
} catch (e) {
error('Local file sync load failed', getSafeErrorMeta(e));
return createSafeIpcError(IPC.FILE_SYNC_LOAD, e);
}
},
);
ipcMain.handle(
IPC.FILE_SYNC_REMOVE,
async (
ev,
{
relativePath,
}: {
relativePath: string;
},
): Promise<void | Error> => {
try {
const filePath = (await _resolveRelative(relativePath)).absolutePath;
log(IPC.FILE_SYNC_REMOVE);
unlinkSync(filePath);
return;
} catch (e) {
error('Local file sync remove failed', getSafeErrorMeta(e));
return createSafeIpcError(IPC.FILE_SYNC_REMOVE, e);
}
},
);
ipcMain.handle(
IPC.CHECK_DIR_EXISTS,
async (
ev,
{
relativePath,
}: {
relativePath?: string;
},
): Promise<true | Error> => {
try {
// Default to the sync root itself (relativePath = '') so the legacy
// "is the configured sync folder reachable?" check still works.
const dirPath = (await _resolveRelative(relativePath)).absolutePath;
const dirEntries = readdirSync(dirPath);
log(IPC.CHECK_DIR_EXISTS, {
dirEntryCount: dirEntries.length,
});
return true;
} catch (e) {
error('Local file sync directory check failed', getSafeErrorMeta(e));
if ((e as NodeJS.ErrnoException).code === 'EACCES') {
log(
'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.',
);
}
return createSafeIpcError(IPC.CHECK_DIR_EXISTS, e);
}
},
);
ipcMain.handle(
IPC.FILE_SYNC_LIST_FILES,
async (
ev,
{
relativePath,
}: {
relativePath?: string;
},
): Promise<string[] | Error> => {
try {
const dirPath = (await _resolveRelative(relativePath)).absolutePath;
return readdirSync(dirPath);
} catch (e) {
error('Local file sync list files failed', getSafeErrorMeta(e));
if ((e as NodeJS.ErrnoException).code === 'EACCES') {
log(
'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.',
);
}
return createSafeIpcError(IPC.FILE_SYNC_LIST_FILES, e);
}
},
);
ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | Error | undefined> => {
try {
const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), {
title: 'Select sync folder',
buttonLabel: 'Select Folder',
properties: [
'openDirectory',
'createDirectory',
'promptToCreate',
'dontAddToRecent',
],
})) as unknown as { canceled: boolean; filePaths: string[] };
if (canceled || !filePaths[0]) {
return undefined;
}
// Persist main-side BEFORE returning the display string. If
// canonicalization or persistence fails (deleted between pick and
// commit, EACCES on userData, etc.), surface a safe error rather than
// a silent undefined — otherwise the renderer cannot distinguish
// failure from user-cancel and the user is left wondering why their
// pick didn't take.
return await setSyncFolderPath(filePaths[0]);
} catch (e) {
error('PICK_DIRECTORY failed to persist sync folder', getSafeErrorMeta(e));
return createSafeIpcError(IPC.PICK_DIRECTORY, e);
}
});
ipcMain.handle(IPC.GET_SYNC_FOLDER_PATH, async (): Promise<string | null> => {
return getSyncFolderPath();
});
ipcMain.handle(
IPC.IMAGE_PICK_AND_IMPORT,
async (): Promise<{ id: string; mimeType: string } | null | Error> => {
// SECURITY: the dialog + import are atomic and run together in main.
// The renderer never holds the absolute path — it cannot trigger an
// image read without a user clicking through the native picker.
// (The previous shape exposed an `importImage(path)` IPC that the
// renderer could call with any image-extension path it pleased; this
// version closes that gap.)
try {
const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), {
title: 'Select image',
buttonLabel: 'Select',
properties: ['openFile'],
filters: [
{
name: 'Images',
// Keep in sync with MIME_BY_EXT in image-cache.ts (svg excluded
// on purpose — scriptable format).
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif'],
},
],
})) as unknown as { canceled: boolean; filePaths: string[] };
if (canceled || !filePaths[0]) {
// User cancelled — distinct from validation failure so the renderer
// can stay silent instead of showing a "couldn't read image" snack.
return null;
}
const imported = await importImage(filePaths[0]);
if (!imported) {
// Reject as an error: validation failed (extension, size, etc.) so
// the renderer surfaces the error path, not the cancel path. The
// safe-error wrapper below strips the stack and renames the error
// so it matches the FS handlers' contract — main bundle paths
// (from a raw `e.stack`) are not leaked.
throw new Error('Selected image could not be imported');
}
return imported;
} catch (e) {
error('Image pick-and-import failed', getSafeErrorMeta(e));
return createSafeIpcError(IPC.IMAGE_PICK_AND_IMPORT, e);
}
},
);
ipcMain.handle(
IPC.IMAGE_CACHE_GET_DATA_URL,
async (_, id: string): Promise<string | null> => {
return getImageDataUrl(id);
},
);
ipcMain.handle(
IPC.SHOW_OPEN_DIALOG,
async (
_,
options: {
properties: string[];
title?: string;
defaultPath?: string;
filters?: { name: string; extensions: string[] }[];
},
): Promise<string[] | undefined> => {
const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), {
title: options.title || 'Select folder',
buttonLabel: 'Select',
properties: options.properties as any,
defaultPath: options.defaultPath,
filters: options.filters,
})) as unknown as { canceled: boolean; filePaths: string[] };
if (canceled) {
return undefined;
} else {
return filePaths;
}
},
);
};
const getRev = (filePath: string): string => {
const fileStat = statSync(filePath);
return fileStat.mtime.getTime().toString();
};
const getSafeErrorMeta = (
e: unknown,
): {
errorName: string;
errorCode?: string | number;
} => {
const errorName =
e instanceof Error
? e.name
: typeof e === 'object' && e !== null && 'name' in e && typeof e.name === 'string'
? e.name
: 'UnknownError';
const errorCode =
typeof e === 'object' &&
e !== null &&
'code' in e &&
(typeof e.code === 'string' || typeof e.code === 'number')
? e.code
: undefined;
return errorCode === undefined ? { errorName } : { errorName, errorCode };
};
const createSafeIpcError = (operation: IPC, e: unknown): Error => {
const { errorName, errorCode } = getSafeErrorMeta(e);
const codeMessagePart = errorCode === undefined ? '' : ` (code: ${errorCode})`;
const safeError = new Error(`${operation} failed: ${errorName}${codeMessagePart}`, {
cause: { name: errorName, code: errorCode },
}) as Error & { code?: string | number };
safeError.name = errorName;
if (errorCode !== undefined) {
safeError.code = errorCode;
}
delete safeError.stack;
return safeError;
};