mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(electron): simplify sync folder ownership + stop renderer writes
Post-review of Phase 2 (#8228). The reviewer flagged one real bug and several pieces of premature scaffolding. Real bug — finding #1: the sync form was still writing the sync folder path back into the renderer credential store. Phase 2 made the IPCs take a relative path and main own the canonical root, but the Formly button at sync-form.const.ts:200 had `key: 'syncFolderPath'`, which caused FormlyBtn.onClick to setValue(picker-return-string) on the form model. That flowed into privateCfg.syncFolderPath on save. The "renderer no longer holds the authoritative copy" claim was true in the IPC contract but untrue at runtime. A compromised renderer could keep mutating its own privateCfg.syncFolderPath; nothing on master reads it after Phase 2, but any future code path that does would re-open the hole. Fix: - drop `syncFolderPath` from PROVIDER_FIELD_DEFAULTS[LocalFile] so it is not in the LocalFile credential-store defaults at all - drop `key: 'syncFolderPath'` from the LocalFile picker button so the picker's return value is not bound to the form model KISS simplifications (a/b/d from review): - delete electron/sync-folder-store.ts and its test; inline the load/cache/persist helpers at the top of local-file-sync.ts as ~25 LOC. The dedicated module was carrying an init/get/set ceremony and a Promise singleton for a single string that is read at most a few times per second in the worst case. simple-store is the durable layer either way. - drop the `_resolveOrThrow` typeof precheck; resolveSyncPath already type-checks `relativePath` and throws the same opaque error - inline `resolveSyncPath(...).absolutePath` into each handler; the helper was the third layer doing the same thing Other findings addressed: - finding #2 (PICK_DIRECTORY silently undefined on persist failure): PICK_DIRECTORY now returns a safe Error via createSafeIpcError when realpath/persist fails, distinct from `undefined` for user-cancel. New tests cover both branches plus the "cache must not be poisoned with a non-existent path" invariant. - finding #4 (set-without-canonicalize): the inlined setSyncFolderPath now `realpathSync.native`s the picked path before persisting, so a relative or symlinked picker result is rejected early and the on-disk form matches what resolveSyncPath compares against on read. - finding #5 (fire-and-forget init that was also being awaited per handler): dropped the fire-and-forget; handlers just `await getSyncFolderPath()`, which lazy-loads from disk on first call and caches. All 52 electron security tests still pass; lint clean; tsc clean.
This commit is contained in:
parent
0c21649fde
commit
00e71b407f
6 changed files with 133 additions and 294 deletions
|
|
@ -2,18 +2,9 @@
|
||||||
* Security tests for the local file-sync IPC handlers.
|
* Security tests for the local file-sync IPC handlers.
|
||||||
*
|
*
|
||||||
* Post-issue-#8228 the handlers only accept a `relativePath`; the renderer
|
* Post-issue-#8228 the handlers only accept a `relativePath`; the renderer
|
||||||
* never supplies an absolute path. Main owns the sync folder via
|
* never supplies an absolute path. The sync folder is owned and persisted
|
||||||
* `sync-folder-store` and resolves the relative path against it. These tests
|
* main-side (the cache lives at the top of `local-file-sync.ts`) and the
|
||||||
* cover:
|
* relative path is resolved against it via `sync-path-resolver`.
|
||||||
*
|
|
||||||
* - happy path inside a user-chosen sync folder
|
|
||||||
* - relative-path traversal (`..`) escape attempts
|
|
||||||
* - sync folder pointed at userData (must be denied at config time so the
|
|
||||||
* renderer cannot forge a grant file via the sync API)
|
|
||||||
* - absent sync folder
|
|
||||||
* - unchanged image-inlining and to-file-url guards
|
|
||||||
*
|
|
||||||
* Run via the standard electron .test.cjs runner.
|
|
||||||
*/
|
*/
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
@ -26,22 +17,22 @@ require('ts-node/register/transpile-only');
|
||||||
|
|
||||||
const modPath = path.resolve(__dirname, 'local-file-sync.ts');
|
const modPath = path.resolve(__dirname, 'local-file-sync.ts');
|
||||||
const simpleStorePath = path.resolve(__dirname, 'simple-store.ts');
|
const simpleStorePath = path.resolve(__dirname, 'simple-store.ts');
|
||||||
const syncFolderStorePath = path.resolve(__dirname, 'sync-folder-store.ts');
|
|
||||||
const syncPathResolverPath = path.resolve(__dirname, 'sync-path-resolver.ts');
|
const syncPathResolverPath = path.resolve(__dirname, 'sync-path-resolver.ts');
|
||||||
const originalModuleLoad = Module._load;
|
const originalModuleLoad = Module._load;
|
||||||
|
|
||||||
let handlers = {};
|
let handlers = {};
|
||||||
let userDataDir;
|
let userDataDir;
|
||||||
let externalDir;
|
let externalDir;
|
||||||
|
let nextDialogResult = { canceled: true, filePaths: [] };
|
||||||
|
|
||||||
const installMocks = (overrides = {}) => {
|
const installMocks = () => {
|
||||||
Module._load = function patchedLoad(request, parent, isMain) {
|
Module._load = function patchedLoad(request, parent, isMain) {
|
||||||
if (request === 'electron') {
|
if (request === 'electron') {
|
||||||
return {
|
return {
|
||||||
ipcMain: { handle: (channel, fn) => (handlers[channel] = fn) },
|
ipcMain: { handle: (channel, fn) => (handlers[channel] = fn) },
|
||||||
app: { getPath: () => userDataDir },
|
app: { getPath: () => userDataDir },
|
||||||
dialog: overrides.dialog || {
|
dialog: {
|
||||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
showOpenDialog: async () => nextDialogResult,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +49,6 @@ const installMocks = (overrides = {}) => {
|
||||||
const resetCaches = () => {
|
const resetCaches = () => {
|
||||||
delete require.cache[modPath];
|
delete require.cache[modPath];
|
||||||
delete require.cache[simpleStorePath];
|
delete require.cache[simpleStorePath];
|
||||||
delete require.cache[syncFolderStorePath];
|
|
||||||
delete require.cache[syncPathResolverPath];
|
delete require.cache[syncPathResolverPath];
|
||||||
handlers = {};
|
handlers = {};
|
||||||
};
|
};
|
||||||
|
|
@ -69,17 +59,17 @@ const load = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const configureSyncFolder = async (folder) => {
|
const configureSyncFolder = async (folder) => {
|
||||||
const store = require(syncFolderStorePath);
|
// Drive the real PICK_DIRECTORY path so the test exercises the same
|
||||||
await store.setSyncFolderPath(folder);
|
// canonicalize-and-persist code production uses.
|
||||||
|
nextDialogResult = { canceled: false, filePaths: [folder] };
|
||||||
|
const result = await handlers['PICK_DIRECTORY']({});
|
||||||
|
if (result instanceof Error) throw result;
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
test.beforeEach(() => {
|
test.beforeEach(() => {
|
||||||
userDataDir = fs.realpathSync.native(
|
userDataDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ud-')));
|
||||||
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ud-')),
|
externalDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ext-')));
|
||||||
);
|
|
||||||
externalDir = fs.realpathSync.native(
|
|
||||||
fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ext-')),
|
|
||||||
);
|
|
||||||
installMocks();
|
installMocks();
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
|
|
@ -225,25 +215,35 @@ test('GET_SYNC_FOLDER_PATH returns null when unconfigured, then the configured p
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PICK_DIRECTORY persists the selection main-side', async () => {
|
test('PICK_DIRECTORY persists the selection main-side', async () => {
|
||||||
// Re-install mocks with a non-cancelling dialog.
|
nextDialogResult = { canceled: false, filePaths: [externalDir] };
|
||||||
Module._load = originalModuleLoad;
|
|
||||||
const picked = externalDir;
|
|
||||||
installMocks({
|
|
||||||
dialog: {
|
|
||||||
showOpenDialog: async () => ({ canceled: false, filePaths: [picked] }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
load();
|
|
||||||
|
|
||||||
const returned = await handlers['PICK_DIRECTORY']({});
|
const returned = await handlers['PICK_DIRECTORY']({});
|
||||||
assert.equal(returned, picked);
|
assert.equal(returned, externalDir);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await handlers['GET_SYNC_FOLDER_PATH']({}),
|
await handlers['GET_SYNC_FOLDER_PATH']({}),
|
||||||
picked,
|
externalDir,
|
||||||
'main-side store is updated; renderer does not have to echo back',
|
'main-side store is updated; renderer does not have to echo back',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PICK_DIRECTORY surfaces a safe error when persistence fails', async () => {
|
||||||
|
// Picker returns a path that no longer exists on disk → realpath throws
|
||||||
|
// → PICK_DIRECTORY must return a distinct Error, not undefined, so the
|
||||||
|
// renderer doesn't confuse persist-failure with user-cancel.
|
||||||
|
const gone = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-gone-'));
|
||||||
|
fs.rmSync(gone, { recursive: true, force: true });
|
||||||
|
nextDialogResult = { canceled: false, filePaths: [gone] };
|
||||||
|
const result = await handlers['PICK_DIRECTORY']({});
|
||||||
|
assert.ok(result instanceof Error);
|
||||||
|
// Cache must not be poisoned with a non-existent path.
|
||||||
|
assert.equal(await handlers['GET_SYNC_FOLDER_PATH']({}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PICK_DIRECTORY returns undefined on user-cancel (distinct from error)', async () => {
|
||||||
|
nextDialogResult = { canceled: true, filePaths: [] };
|
||||||
|
const result = await handlers['PICK_DIRECTORY']({});
|
||||||
|
assert.equal(result, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('READ_LOCAL_IMAGE_AS_DATA_URL refuses an image inside userData', async () => {
|
test('READ_LOCAL_IMAGE_AS_DATA_URL refuses an image inside userData', async () => {
|
||||||
fs.writeFileSync(path.join(userDataDir, 'secret.png'), 'not-really-png');
|
fs.writeFileSync(path.join(userDataDir, 'secret.png'), 'not-really-png');
|
||||||
const result = await handlers['READ_LOCAL_IMAGE_AS_DATA_URL'](
|
const result = await handlers['READ_LOCAL_IMAGE_AS_DATA_URL'](
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { IPC } from './shared-with-frontend/ipc-events.const';
|
||||||
import {
|
import {
|
||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
renameSync,
|
renameSync,
|
||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
|
|
@ -13,11 +14,7 @@ import { getWin } from './main-window';
|
||||||
import { fileURLToPath, pathToFileURL } from 'url';
|
import { fileURLToPath, pathToFileURL } from 'url';
|
||||||
import { assertPathOutside } from './file-path-guard';
|
import { assertPathOutside } from './file-path-guard';
|
||||||
import { resolveSyncPath } from './sync-path-resolver';
|
import { resolveSyncPath } from './sync-path-resolver';
|
||||||
import {
|
import { loadSimpleStoreAll, saveSimpleStore } from './simple-store';
|
||||||
getSyncFolderPath,
|
|
||||||
initSyncFolderStore,
|
|
||||||
setSyncFolderPath,
|
|
||||||
} from './sync-folder-store';
|
|
||||||
|
|
||||||
// SECURITY: file-sync must never read/write/list inside the app's private dir,
|
// 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
|
// which holds settings/grants/db — touching it is a privilege-escalation
|
||||||
|
|
@ -26,29 +23,42 @@ import {
|
||||||
// changed at startup (--user-data-dir, Snap). See file-path-guard.ts.
|
// changed at startup (--user-data-dir, Snap). See file-path-guard.ts.
|
||||||
const getAppPrivateDir = (): string => app.getPath('userData');
|
const getAppPrivateDir = (): string => app.getPath('userData');
|
||||||
|
|
||||||
// Resolve a renderer-supplied relativePath against the main-owned sync folder.
|
// Main-owned sync folder path. Backed by simple-store under SYNC_FOLDER_KEY;
|
||||||
// Throws a generic, path-free error so the offending input never round-trips
|
// cached in-memory after first load so each FS IPC doesn't re-read the file.
|
||||||
// to the renderer via createSafeIpcError.
|
// The canonical form is stored so subsequent realpath comparisons in
|
||||||
const _resolveOrThrow = (relativePath: unknown): string => {
|
// resolveSyncPath line up regardless of symlink/case-fold drift.
|
||||||
if (typeof relativePath !== 'string') {
|
const SYNC_FOLDER_KEY = 'syncFolderPath';
|
||||||
const e = new Error('Path not allowed for the sync folder');
|
let _cachedSyncFolder: string | null | undefined = undefined;
|
||||||
e.name = 'PathNotAllowedError';
|
let _loadPromise: Promise<string | null> | null = null;
|
||||||
delete (e as { stack?: string }).stack;
|
|
||||||
throw e;
|
const _loadSyncFolderFromDisk = async (): Promise<string | null> => {
|
||||||
|
const all = await loadSimpleStoreAll();
|
||||||
|
const raw = all[SYNC_FOLDER_KEY];
|
||||||
|
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) => {
|
||||||
|
_cachedSyncFolder = v;
|
||||||
|
return v;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return resolveSyncPath(
|
return _loadPromise;
|
||||||
getSyncFolderPath() ?? undefined,
|
};
|
||||||
relativePath,
|
|
||||||
getAppPrivateDir(),
|
const setSyncFolderPath = async (rawPath: string): Promise<string> => {
|
||||||
).absolutePath;
|
// 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);
|
||||||
|
await saveSimpleStore(SYNC_FOLDER_KEY, canonical);
|
||||||
|
_cachedSyncFolder = canonical;
|
||||||
|
return canonical;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initLocalFileSyncAdapter = (): void => {
|
export const initLocalFileSyncAdapter = (): void => {
|
||||||
// Fire-and-forget: subsequent IPC handlers also `await initSyncFolderStore()`
|
|
||||||
// defensively (it is idempotent via _loadOnce), so we don't need to gate
|
|
||||||
// handler registration on this promise resolving.
|
|
||||||
void initSyncFolderStore();
|
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.READ_LOCAL_IMAGE_AS_DATA_URL,
|
IPC.READ_LOCAL_IMAGE_AS_DATA_URL,
|
||||||
async (_, filePathOrUrl: string): Promise<string | null> => {
|
async (_, filePathOrUrl: string): Promise<string | null> => {
|
||||||
|
|
@ -124,8 +134,11 @@ export const initLocalFileSyncAdapter = (): void => {
|
||||||
},
|
},
|
||||||
): Promise<string | Error> => {
|
): Promise<string | Error> => {
|
||||||
try {
|
try {
|
||||||
await initSyncFolderStore();
|
const filePath = resolveSyncPath(
|
||||||
const filePath = _resolveOrThrow(relativePath);
|
(await getSyncFolderPath()) ?? undefined,
|
||||||
|
relativePath,
|
||||||
|
getAppPrivateDir(),
|
||||||
|
).absolutePath;
|
||||||
log(IPC.FILE_SYNC_SAVE, {
|
log(IPC.FILE_SYNC_SAVE, {
|
||||||
dataLength: dataStr.length,
|
dataLength: dataStr.length,
|
||||||
hasData: dataStr.length > 0,
|
hasData: dataStr.length > 0,
|
||||||
|
|
@ -159,8 +172,11 @@ export const initLocalFileSyncAdapter = (): void => {
|
||||||
},
|
},
|
||||||
): Promise<{ rev: string; dataStr: string | undefined } | Error> => {
|
): Promise<{ rev: string; dataStr: string | undefined } | Error> => {
|
||||||
try {
|
try {
|
||||||
await initSyncFolderStore();
|
const filePath = resolveSyncPath(
|
||||||
const filePath = _resolveOrThrow(relativePath);
|
(await getSyncFolderPath()) ?? undefined,
|
||||||
|
relativePath,
|
||||||
|
getAppPrivateDir(),
|
||||||
|
).absolutePath;
|
||||||
log(IPC.FILE_SYNC_LOAD, {
|
log(IPC.FILE_SYNC_LOAD, {
|
||||||
hasLocalRev: !!localRev,
|
hasLocalRev: !!localRev,
|
||||||
});
|
});
|
||||||
|
|
@ -190,8 +206,11 @@ export const initLocalFileSyncAdapter = (): void => {
|
||||||
},
|
},
|
||||||
): Promise<void | Error> => {
|
): Promise<void | Error> => {
|
||||||
try {
|
try {
|
||||||
await initSyncFolderStore();
|
const filePath = resolveSyncPath(
|
||||||
const filePath = _resolveOrThrow(relativePath);
|
(await getSyncFolderPath()) ?? undefined,
|
||||||
|
relativePath,
|
||||||
|
getAppPrivateDir(),
|
||||||
|
).absolutePath;
|
||||||
log(IPC.FILE_SYNC_REMOVE);
|
log(IPC.FILE_SYNC_REMOVE);
|
||||||
unlinkSync(filePath);
|
unlinkSync(filePath);
|
||||||
return;
|
return;
|
||||||
|
|
@ -213,9 +232,13 @@ export const initLocalFileSyncAdapter = (): void => {
|
||||||
},
|
},
|
||||||
): Promise<true | Error> => {
|
): Promise<true | Error> => {
|
||||||
try {
|
try {
|
||||||
await initSyncFolderStore();
|
// Default to the sync root itself (relativePath = '') so the legacy
|
||||||
// Default to operating on the sync root itself (relativePath = '').
|
// "is the configured sync folder reachable?" check still works.
|
||||||
const dirPath = _resolveOrThrow(relativePath ?? '');
|
const dirPath = resolveSyncPath(
|
||||||
|
(await getSyncFolderPath()) ?? undefined,
|
||||||
|
relativePath ?? '',
|
||||||
|
getAppPrivateDir(),
|
||||||
|
).absolutePath;
|
||||||
const dirEntries = readdirSync(dirPath);
|
const dirEntries = readdirSync(dirPath);
|
||||||
log(IPC.CHECK_DIR_EXISTS, {
|
log(IPC.CHECK_DIR_EXISTS, {
|
||||||
dirEntryCount: dirEntries.length,
|
dirEntryCount: dirEntries.length,
|
||||||
|
|
@ -244,8 +267,11 @@ export const initLocalFileSyncAdapter = (): void => {
|
||||||
},
|
},
|
||||||
): Promise<string[] | Error> => {
|
): Promise<string[] | Error> => {
|
||||||
try {
|
try {
|
||||||
await initSyncFolderStore();
|
const dirPath = resolveSyncPath(
|
||||||
const dirPath = _resolveOrThrow(relativePath ?? '');
|
(await getSyncFolderPath()) ?? undefined,
|
||||||
|
relativePath ?? '',
|
||||||
|
getAppPrivateDir(),
|
||||||
|
).absolutePath;
|
||||||
return readdirSync(dirPath);
|
return readdirSync(dirPath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error('Local file sync list files failed', getSafeErrorMeta(e));
|
error('Local file sync list files failed', getSafeErrorMeta(e));
|
||||||
|
|
@ -259,39 +285,35 @@ export const initLocalFileSyncAdapter = (): void => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | undefined> => {
|
ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | Error | undefined> => {
|
||||||
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) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const selected = filePaths[0];
|
|
||||||
if (!selected) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Persist main-side immediately so the renderer never holds the
|
|
||||||
// authoritative copy of the sync folder path. The renderer may still
|
|
||||||
// receive the path for display, but a subsequent IPC will look up the
|
|
||||||
// stored value rather than trusting whatever the renderer echoes back.
|
|
||||||
try {
|
try {
|
||||||
await setSyncFolderPath(selected);
|
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) {
|
} catch (e) {
|
||||||
error('Failed to persist sync folder selection', getSafeErrorMeta(e));
|
error('PICK_DIRECTORY failed to persist sync folder', getSafeErrorMeta(e));
|
||||||
return undefined;
|
return createSafeIpcError(IPC.PICK_DIRECTORY, e);
|
||||||
}
|
}
|
||||||
return selected;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC.GET_SYNC_FOLDER_PATH, async (): Promise<string | null> => {
|
ipcMain.handle(IPC.GET_SYNC_FOLDER_PATH, async (): Promise<string | null> => {
|
||||||
await initSyncFolderStore();
|
|
||||||
return getSyncFolderPath();
|
return getSyncFolderPath();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -364,3 +386,9 @@ const createSafeIpcError = (operation: IPC, e: unknown): Error => {
|
||||||
|
|
||||||
return safeError;
|
return safeError;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Test-only: clear the in-memory cache so the next read re-loads from disk. */
|
||||||
|
export const __resetSyncFolderCacheForTests = (): void => {
|
||||||
|
_cachedSyncFolder = undefined;
|
||||||
|
_loadPromise = null;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { promises: fs } = require('node:fs');
|
|
||||||
const Module = require('node:module');
|
|
||||||
|
|
||||||
require('ts-node/register/transpile-only');
|
|
||||||
|
|
||||||
const originalModuleLoad = Module._load;
|
|
||||||
let userDataDir;
|
|
||||||
|
|
||||||
const simpleStoreModulePath = path.resolve(__dirname, 'simple-store.ts');
|
|
||||||
const syncFolderStoreModulePath = path.resolve(__dirname, 'sync-folder-store.ts');
|
|
||||||
|
|
||||||
const installMocks = () => {
|
|
||||||
Module._load = function patchedLoad(request, parent, isMain) {
|
|
||||||
if (request === 'electron') {
|
|
||||||
return { app: { getPath: () => userDataDir } };
|
|
||||||
}
|
|
||||||
if (request === 'electron-log/main') {
|
|
||||||
return { log: () => {}, error: () => {} };
|
|
||||||
}
|
|
||||||
return originalModuleLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetModules = () => {
|
|
||||||
delete require.cache[simpleStoreModulePath];
|
|
||||||
delete require.cache[syncFolderStoreModulePath];
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadStore = () => {
|
|
||||||
resetModules();
|
|
||||||
return require(syncFolderStoreModulePath);
|
|
||||||
};
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-folder-store-'));
|
|
||||||
installMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
Module._load = originalModuleLoad;
|
|
||||||
resetModules();
|
|
||||||
await fs.rm(userDataDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getSyncFolderPath returns null on first init (no persisted value)', async () => {
|
|
||||||
const store = loadStore();
|
|
||||||
await store.initSyncFolderStore();
|
|
||||||
assert.equal(store.getSyncFolderPath(), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSyncFolderPath persists and is readable across reloads', async () => {
|
|
||||||
const store = loadStore();
|
|
||||||
await store.initSyncFolderStore();
|
|
||||||
await store.setSyncFolderPath('/Users/me/sync');
|
|
||||||
assert.equal(store.getSyncFolderPath(), '/Users/me/sync');
|
|
||||||
|
|
||||||
store.__resetSyncFolderCacheForTests();
|
|
||||||
await store.initSyncFolderStore();
|
|
||||||
assert.equal(store.getSyncFolderPath(), '/Users/me/sync');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSyncFolderPath(null) clears the persisted value', async () => {
|
|
||||||
const store = loadStore();
|
|
||||||
await store.initSyncFolderStore();
|
|
||||||
await store.setSyncFolderPath('/Users/me/sync');
|
|
||||||
await store.setSyncFolderPath(null);
|
|
||||||
assert.equal(store.getSyncFolderPath(), null);
|
|
||||||
|
|
||||||
store.__resetSyncFolderCacheForTests();
|
|
||||||
await store.initSyncFolderStore();
|
|
||||||
assert.equal(store.getSyncFolderPath(), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSyncFolderPath(empty string) is treated as null', async () => {
|
|
||||||
const store = loadStore();
|
|
||||||
await store.initSyncFolderStore();
|
|
||||||
await store.setSyncFolderPath('/some/path');
|
|
||||||
await store.setSyncFolderPath('');
|
|
||||||
assert.equal(store.getSyncFolderPath(), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getSyncFolderPath throws when called before init', async () => {
|
|
||||||
const store = loadStore();
|
|
||||||
assert.throws(() => store.getSyncFolderPath(), /initSyncFolderStore/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setSyncFolderPath is idempotent (no persist if value unchanged)', async () => {
|
|
||||||
// Indirectly verified: a no-op set must not corrupt cache state on the
|
|
||||||
// next read. (We can't observe the file write count without monkey-patching
|
|
||||||
// simple-store; this guards against the regression where idempotence
|
|
||||||
// accidentally inverts the cache.)
|
|
||||||
const store = loadStore();
|
|
||||||
await store.initSyncFolderStore();
|
|
||||||
await store.setSyncFolderPath('/x');
|
|
||||||
await store.setSyncFolderPath('/x');
|
|
||||||
await store.setSyncFolderPath('/x');
|
|
||||||
assert.equal(store.getSyncFolderPath(), '/x');
|
|
||||||
});
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { loadSimpleStoreAll, saveSimpleStore } from './simple-store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main-owned, persisted location of the user's sync folder.
|
|
||||||
*
|
|
||||||
* Background (issue #8228): before this store, the renderer held the sync
|
|
||||||
* folder path in its own IndexedDB (`privateCfg.syncFolderPath`) and echoed
|
|
||||||
* it back as an absolute path on every FILE_SYNC_* IPC. That meant a
|
|
||||||
* compromised renderer could swap the path for any other absolute path
|
|
||||||
* outside `userData` and get unrestricted read/write. With the path owned
|
|
||||||
* by main, the renderer only ever sends the *relative* part; main resolves
|
|
||||||
* against the path it stored itself.
|
|
||||||
*
|
|
||||||
* Persistence piggybacks on `simple-store` so we get the same durability
|
|
||||||
* mechanics (atomic write, corruption quarantine, EISDIR recovery, queue).
|
|
||||||
* An in-memory cache backs the hot path so per-IPC lookup does not stat
|
|
||||||
* the simpleSettings file every call.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SIMPLE_STORE_KEY = 'syncFolderPath';
|
|
||||||
|
|
||||||
interface CacheState {
|
|
||||||
value: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _cache: CacheState | null = null;
|
|
||||||
let _loadOnce: Promise<void> | null = null;
|
|
||||||
|
|
||||||
const _loadIntoCache = async (): Promise<void> => {
|
|
||||||
if (_loadOnce) return _loadOnce;
|
|
||||||
_loadOnce = (async () => {
|
|
||||||
const all = await loadSimpleStoreAll();
|
|
||||||
const raw = all[SIMPLE_STORE_KEY];
|
|
||||||
_cache = { value: typeof raw === 'string' && raw.length > 0 ? raw : null };
|
|
||||||
})();
|
|
||||||
return _loadOnce;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the persisted value into the in-memory cache. MUST be called once at
|
|
||||||
* app startup before any IPC handler reads via {@link getSyncFolderPath}.
|
|
||||||
*/
|
|
||||||
export const initSyncFolderStore = async (): Promise<void> => {
|
|
||||||
await _loadIntoCache();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronously return the current sync folder path, or null if none is set.
|
|
||||||
*
|
|
||||||
* Callers must have invoked {@link initSyncFolderStore} during app startup.
|
|
||||||
* Throws — rather than silently returning null — so a missed startup wiring
|
|
||||||
* is loud during development, not a silent permissive-by-default surprise
|
|
||||||
* in production.
|
|
||||||
*/
|
|
||||||
export const getSyncFolderPath = (): string | null => {
|
|
||||||
if (!_cache) {
|
|
||||||
throw new Error(
|
|
||||||
'sync-folder-store: initSyncFolderStore() must be called before getSyncFolderPath()',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _cache.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist a new sync folder path. Pass null to clear.
|
|
||||||
*
|
|
||||||
* The caller is responsible for having validated that `value` is a path the
|
|
||||||
* user explicitly approved (typically the result of a main-process file
|
|
||||||
* dialog). This module trusts its input — it is only reachable from main
|
|
||||||
* code.
|
|
||||||
*/
|
|
||||||
export const setSyncFolderPath = async (value: string | null): Promise<void> => {
|
|
||||||
await _loadIntoCache();
|
|
||||||
if (!_cache) {
|
|
||||||
throw new Error('sync-folder-store: cache failed to initialize');
|
|
||||||
}
|
|
||||||
const next = typeof value === 'string' && value.length > 0 ? value : null;
|
|
||||||
if (_cache.value === next) return;
|
|
||||||
_cache.value = next;
|
|
||||||
await saveSimpleStore(SIMPLE_STORE_KEY, next);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Test-only: drop the in-memory cache so a fresh init re-reads disk. */
|
|
||||||
export const __resetSyncFolderCacheForTests = (): void => {
|
|
||||||
_cache = null;
|
|
||||||
_loadOnce = null;
|
|
||||||
};
|
|
||||||
|
|
@ -196,8 +196,10 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// No `key` on purpose: post-#8228 the sync folder path is owned
|
||||||
|
// main-side. The picker's return value is for display only and
|
||||||
|
// must not write back into the renderer credential store.
|
||||||
type: 'btn',
|
type: 'btn',
|
||||||
key: 'syncFolderPath',
|
|
||||||
templateOptions: {
|
templateOptions: {
|
||||||
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
|
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
|
||||||
btnStyle: 'stroked',
|
btnStyle: 'stroked',
|
||||||
|
|
@ -209,10 +211,6 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
||||||
return (localProvider as LocalFileSyncPicker | undefined)?.pickDirectory();
|
return (localProvider as LocalFileSyncPicker | undefined)?.pickDirectory();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expressions: {
|
|
||||||
'props.required': (field: FormlyFieldConfig) =>
|
|
||||||
field?.parent?.parent?.model?.syncProvider === SyncProviderId.LocalFile,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ const PROVIDER_FIELD_DEFAULTS: Record<
|
||||||
encryptKey: '',
|
encryptKey: '',
|
||||||
},
|
},
|
||||||
[SyncProviderId.LocalFile]: {
|
[SyncProviderId.LocalFile]: {
|
||||||
syncFolderPath: '',
|
// syncFolderPath is intentionally omitted: post-#8228 the sync folder
|
||||||
|
// path is owned main-side (electron/local-file-sync.ts) so a compromised
|
||||||
|
// renderer cannot rewrite it via the credential store.
|
||||||
encryptKey: '',
|
encryptKey: '',
|
||||||
},
|
},
|
||||||
[SyncProviderId.Dropbox]: {
|
[SyncProviderId.Dropbox]: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue