From d335f2ebf1c929e7655cb795fcbfd0ccb25e10b8 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 29 Sep 2026 22:52:46 +0300 Subject: [PATCH 01/37] build: update electron --- package-lock.json | 110 +++++++++++++++----------------------- packages/app/package.json | 2 +- 2 files changed, 44 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9653dee7a..be3cfc8f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4895,6 +4895,16 @@ "node": ">= 14.17.5" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -17407,22 +17417,22 @@ } }, "node_modules/electron": { - "version": "39.8.10", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz", - "integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==", + "version": "44.4.5", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.4.5.tgz", + "integrity": "sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-devtools-installer": { @@ -17788,89 +17798,56 @@ } }, "node_modules/electron/node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" + "undici": "^7.24.4" } }, "node_modules/electron/node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/electron/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/electron/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/electron/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/electron/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, - "node_modules/electron/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -33239,7 +33216,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=20.18.1" } @@ -35663,7 +35639,7 @@ "copy-webpack-plugin": "^13.0.1", "cross-env": "^10.1.0", "css-loader": "^7.1.3", - "electron": "^39.8.2", + "electron": "^44.4.5", "electron-devtools-installer": "^4.0.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "html-webpack-plugin": "^5.6.6", diff --git a/packages/app/package.json b/packages/app/package.json index 3e28407db..3e1b30fa2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -76,7 +76,7 @@ "copy-webpack-plugin": "^13.0.1", "cross-env": "^10.1.0", "css-loader": "^7.1.3", - "electron": "^39.8.2", + "electron": "^44.4.5", "electron-devtools-installer": "^4.0.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "html-webpack-plugin": "^5.6.6", From 5543be926be9bf10a6361bde780c4e309ac330da Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 29 Sep 2026 22:53:01 +0300 Subject: [PATCH 02/37] docs: add instruction for debugging --- docs/debugging.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/debugging.md diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 000000000..7c0ba25fc --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,6 @@ +## Debugging in browser + +To run app in browser mode: +- run `npm run serve --workspace app` +- go to http://localhost:8080/window-main.html +- open console and enable IDB storage via command `localStorage.storageType = 'idb'` \ No newline at end of file From 77cb530f3bd70eb0a86f8c5e94ab715c5d044d25 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 29 Sep 2026 22:54:51 +0300 Subject: [PATCH 03/37] perf: delay auto sync for next Idle --- packages/app/src/core/database/ManagedDatabase.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app/src/core/database/ManagedDatabase.ts b/packages/app/src/core/database/ManagedDatabase.ts index 538453f58..b66390da8 100644 --- a/packages/app/src/core/database/ManagedDatabase.ts +++ b/packages/app/src/core/database/ManagedDatabase.ts @@ -61,9 +61,14 @@ export class ManagedDatabase implements IManagedDatabase { this.dbFile = dbFile; // Auto sync changes - this.debouncedSync = debounce(this.sync, { - wait: sync.delay, - }); + this.debouncedSync = debounce( + () => { + requestIdleCallback(() => this.sync()); + }, + { + wait: sync.delay, + }, + ); this.cleanups.push(dbContainer.onChanged.watch(this.debouncedSync)); } From 20fde1b9c57e839a3e5e3db401fa4e5b9bec2d38 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 30 Sep 2026 21:29:37 +0300 Subject: [PATCH 04/37] test: organize files controller tests --- ...lectronFilesController.integration.test.ts | 446 +++++++++++------- ...onFilesController.integration.test.ts.snap | 30 +- 2 files changed, 299 insertions(+), 177 deletions(-) diff --git a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts index 7f9cc795d..43879bee1 100644 --- a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts +++ b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts @@ -1,4 +1,5 @@ import { vol } from 'memfs'; +import { getRandomBytes } from '@core/encryption/utils/random'; import { enableStorage } from './main'; import { ElectronFilesController, storageApi } from './renderer'; @@ -8,147 +9,274 @@ vi.mock('fs/promises', () => vi.importActual('@mocks/fs/promises')); vi.mock('recursive-readdir', () => vi.importActual('@mocks/recursive-readdir')); vi.mock('electron', () => vi.importActual('@mocks/electron')); -const storageHandlerCleanup = enableStorage(); -const filesController = new ElectronFilesController(storageApi, 'vaultDir'); - const getBufferFromText = (text: string) => new Uint8Array(Buffer.from(text)).buffer; -test('Write files', async () => { - expect(vol.readdirSync('/', { recursive: true })).toEqual([]); - - await filesController.write( - 'test.txt', - new Uint8Array(Buffer.from('File text content')).buffer, - ); - await filesController.write( - '/foo/bar/baz/test.txt', - new Uint8Array(Buffer.from('File text content')).buffer, +const createFiles = ( + files: ElectronFilesController, + fileNames: string[], + content?: ArrayBuffer, +) => + Promise.all( + fileNames.map((id) => files.write(id, content ?? new Uint8Array(100).buffer)), ); - expect(vol.readdirSync('/', { recursive: true })).toMatchSnapshot( - 'FS structure after writing', - ); - await expect(filesController.list()).resolves.toMatchSnapshot( - 'Controller files list after writing', +describe('Chunking', () => { + const cleanups = [] as (() => void)[]; + beforeAll(() => { + vol.reset(); + cleanups.push(enableStorage()); + }); + afterAll(() => { + cleanups.forEach((cleanup) => cleanup()); + }); + + test.each([ + { size: 99, chunkSize: 5 }, + { size: 20, chunkSize: 5 }, + { size: 5, chunkSize: 5 }, + { size: 4.9, chunkSize: 5 }, + { size: 0.1, chunkSize: 5 }, + ])( + 'File reading returns exact equal buffer as original data (chunk size $chunkSize Mb, file size $size Mb)', + async ({ size, chunkSize }) => { + const filesController = new ElectronFilesController( + storageApi, + 'size-test', + undefined, + { chunkSize }, + ); + const originalBuffer = getRandomBytes(1024 ** 2 * size); + + await filesController.write('data', originalBuffer.buffer); + + const bytesFromDisk = await filesController.get('data'); + expect(bytesFromDisk).toBeInstanceOf(ArrayBuffer); + expect(Buffer.from(originalBuffer).equals(Buffer.from(bytesFromDisk!))).toBe( + true, + ); + }, ); }); -test('Check files content', async () => { - await expect( - filesController.list().then((paths) => - Promise.all( - paths.map((path) => - filesController.get(path).then((buffer) => { - if (!buffer) throw new Error('Buffer not found'); - return { - path, - content: Buffer.from(buffer).toString(), - }; - }), +describe('Basic ops', () => { + const cleanups = [] as (() => void)[]; + beforeAll(() => { + cleanups.push(enableStorage()); + }); + afterAll(() => { + cleanups.forEach((cleanup) => cleanup()); + }); + beforeEach(() => { + vol.reset(); + }); + + test('File uploading persist data on disk', async () => { + // No files in FS + expect(vol.readdirSync('/', { recursive: true })).toEqual([]); + + const filesController = new ElectronFilesController(storageApi, 'vaultDir'); + + // Write files + const contentSample = getBufferFromText('File text content'); + await filesController.write('test.txt', contentSample); + await filesController.write('/foo/bar/baz/test.txt', contentSample); + + // Explore files list + await expect(filesController.list()).resolves.toMatchSnapshot( + 'Controller files list after writing', + ); + + expect(vol.readdirSync('/', { recursive: true }), 'Explore FS').toMatchSnapshot( + 'FS structure after writing', + ); + + // Explore files content + await expect( + filesController.list().then((paths) => + Promise.all( + paths.map((path) => + filesController.get(path).then((buffer) => { + if (!buffer) throw new Error('Buffer not found'); + return { + path, + content: Buffer.from(buffer).toString(), + }; + }), + ), ), ), - ), - ).resolves.toMatchSnapshot('Files content'); + ).resolves.toMatchSnapshot('Files content'); + }); }); -test('File may be accessed via absolute and relevant paths both', async () => { - await expect(filesController.get('test.txt')).resolves.toStrictEqual( - getBufferFromText('File text content'), - ); - await expect(filesController.get('/test.txt')).resolves.toStrictEqual( - getBufferFromText('File text content'), - ); - await expect(filesController.get('////test.txt')).resolves.toStrictEqual( - getBufferFromText('File text content'), - ); - await expect(filesController.get('/foo/../test.txt')).resolves.toStrictEqual( - getBufferFromText('File text content'), - ); +describe('Files paths', () => { + const cleanups = [] as (() => void)[]; + beforeAll(() => { + cleanups.push(enableStorage()); + }); + afterAll(() => { + cleanups.forEach((cleanup) => cleanup()); + }); + beforeEach(() => { + vol.reset(); + }); - await expect(filesController.get('/foo/bar/baz/test.txt')).resolves.toStrictEqual( - getBufferFromText('File text content'), - ); - await expect(filesController.get('./foo/bar/baz/test.txt')).resolves.toStrictEqual( - getBufferFromText('File text content'), - ); - await expect(filesController.get('foo/bar/baz/test.txt')).resolves.toStrictEqual( - getBufferFromText('File text content'), - ); -}); + const contentSample = getBufferFromText('File text content'); -describe('Deletion tests', () => { - const files = new ElectronFilesController(storageApi, 'deletionTests'); + test('Files can be fetched by paths with no leading slash', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + await createFiles(files, ['test.txt', '/foo/bar/baz/test.txt'], contentSample); + + await expect(files.get('test.txt')).resolves.toStrictEqual(contentSample); + await expect(files.get('foo/bar/baz/test.txt')).resolves.toStrictEqual( + contentSample, + ); + }); + + test('Files can be fetched by paths with leading slash', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + await createFiles(files, ['test.txt', '/foo/bar/baz/test.txt'], contentSample); + + await expect(files.get('/test.txt')).resolves.toStrictEqual(contentSample); + await expect(files.get('/foo/bar/baz/test.txt')).resolves.toStrictEqual( + contentSample, + ); - test('Add files', async () => { - await expect( - files.write('/root.bytes', new Uint8Array().buffer), - ).resolves.not.toThrow(); - await expect( - files.write('/bytes1', new Uint8Array().buffer), - ).resolves.not.toThrow(); - await expect( - files.write('/bytes2', new Uint8Array().buffer), - ).resolves.not.toThrow(); - await expect( - files.write('/bytes3', new Uint8Array().buffer), - ).resolves.not.toThrow(); - await expect( - files.write('/foo/bytes1', new Uint8Array().buffer), - ).resolves.not.toThrow(); - await expect( - files.write('/foo/bytes2', new Uint8Array().buffer), - ).resolves.not.toThrow(); - await expect( - files.write('/foo/bar/bytes1', new Uint8Array().buffer), - ).resolves.not.toThrow(); await expect( - files.write('/foo/bar/bytes2', new Uint8Array().buffer), - ).resolves.not.toThrow(); + files.get('////test.txt'), + 'Leading slash can be repeated', + ).resolves.toStrictEqual(contentSample); }); - test('Single file may be deleted', async () => { - await expect(files.list()).resolves.toContain('/bytes1'); - await files.delete(['bytes1']); - await expect(files.list()).resolves.not.toContain('/bytes1'); + test('Files can be fetched by relative paths', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + await createFiles(files, ['test.txt', '/foo/bar/baz/test.txt'], contentSample); + + await expect(files.get('/foo/../test.txt')).resolves.toStrictEqual(contentSample); + await expect(files.get('./foo/bar/baz/test.txt')).resolves.toStrictEqual( + contentSample, + ); }); +}); - test('Few files may be deleted', async () => { - await expect(files.list()).resolves.toContain('/bytes2'); - await expect(files.list()).resolves.toContain('/bytes3'); +describe('Path traversal isolation', () => { + const cleanups = [] as (() => void)[]; + beforeAll(() => { + cleanups.push(enableStorage()); + }); + afterAll(() => { + cleanups.forEach((cleanup) => cleanup()); + }); - await files.delete(['bytes2', '/bytes3']); + const contentSample = getBufferFromText('File text content'); + beforeEach(() => { + vol.reset(); - await expect(files.list()).resolves.not.toContain('/bytes2'); - await expect(files.list()).resolves.not.toContain('/bytes3'); + vol.mkdirSync('/home/userData/appDir/app/vaultDir', { recursive: true }); + vol.writeFileSync( + '/home/userData/appDir/app/vaultDir/secret.txt', + Buffer.from(new Uint8Array(contentSample)), + ); }); - test('Directory deletion deletes all its content', async () => { - await expect(files.list()).resolves.toContain('/foo/bar/bytes1'); - await expect(files.list()).resolves.toContain('/foo/bar/bytes2'); + test("Client can't traverse out of root directory", async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); - await files.delete(['/foo/bar']); - await expect(files.list()).resolves.not.toContain('/foo/bar/bytes1'); - await expect(files.list()).resolves.not.toContain('/foo/bar/bytes2'); + await expect(files.get('../vault2/test.bytes')).rejects.toThrowError( + 'Resolved path is out of root directory', + ); + + await expect(files.get('../secret.txt')).rejects.toThrowError( + 'Resolved path is out of root directory', + ); }); - test('Root directory may be deleted', async () => { - await expect(files.list()).resolves.not.toEqual([]); + test('Attempt to write content in directory must throws error', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + await expect(files.write('/', new ArrayBuffer(1))).rejects.toThrowError( + "EISDIR: illegal operation on a directory, open '/home/userData/appDir/app/vaultDir'", + ); + }); - await files.delete(['/']); - await expect(files.list()).resolves.toEqual([]); + test('File can be accessed inside root directory', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + await expect(files.get('./secret.txt')).resolves.toStrictEqual(contentSample); + }); + + test('All ops respects the root limitations', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + + await expect(files.delete(['../secret.txt'])).rejects.toThrowError( + 'Resolved path is out of root directory', + ); + await expect( + files.write('../secret.txt', new Uint8Array().buffer), + ).rejects.toThrowError('Resolved path is out of root directory'); + + // Read file out of root directory + vol.writeFileSync('/home/userData/secret.txt', 'Secret data'); + await expect(files.get('../../secret.txt')).rejects.toThrowError( + 'Resolved path is out of root directory', + ); + }); +}); + +describe('FS backend', () => { + beforeAll(() => { + vol.reset(); + }); + + test('When backend is not started - any ops in controller throws error', async () => { + const filesController = new ElectronFilesController(storageApi, 'vaultDir'); + + await expect(filesController.list()).rejects.toThrow( + 'No handler registered for storage.list', + ); + await expect(filesController.get('/')).rejects.toThrow( + 'No handler registered for storage.get', + ); + await expect(filesController.write('/', new ArrayBuffer(1))).rejects.toThrow( + 'No handler registered for storage.createUploadSession', + ); + await expect(filesController.delete(['/'])).rejects.toThrow( + 'No handler registered for storage.delete', + ); + + // Setup handler + const storageHandlerCleanup = enableStorage(); + onTestFinished(storageHandlerCleanup); + + await expect(filesController.list()).resolves.toStrictEqual([]); + + // Clear handler again + storageHandlerCleanup(); + await expect(filesController.list()).rejects.toThrow( + 'No handler registered for storage.list', + ); }); }); describe('Many clients', () => { - const files1 = new ElectronFilesController(storageApi, 'vault1'); - const files2 = new ElectronFilesController(storageApi, 'vault2'); + const cleanups = [] as (() => void)[]; + beforeAll(() => { + vol.reset(); + cleanups.push(enableStorage()); + }); + afterAll(() => { + cleanups.forEach((cleanup) => cleanup()); + }); - test('Available files is scoped by client root', async () => { + test('Files is scoped by client root directory', async () => { + const files1 = new ElectronFilesController(storageApi, 'dir1'); + const files2 = new ElectronFilesController(storageApi, 'dir2'); + + // Files list is isolated await files1.write('test.bytes', new Uint8Array(1).buffer); await expect(files1.list()).resolves.toEqual(['/test.bytes']); await expect(files2.list()).resolves.toEqual([]); + // Each client can have different files with the same name await files2.write('test.bytes', new Uint8Array(2).buffer); await expect(files1.get('test.bytes')).resolves.toStrictEqual( @@ -158,71 +286,65 @@ describe('Many clients', () => { new Uint8Array(2).buffer, ); }); +}); - test("Client can't traverse out of it's root", async () => { - await expect(files1.get('../vault2/test.bytes')).rejects.toThrowError( - 'Resolved path is out of root directory', - ); +describe('Deletion tests', () => { + const cleanups = [] as (() => void)[]; + beforeAll(() => { + cleanups.push(enableStorage()); + }); + afterAll(() => { + cleanups.forEach((cleanup) => cleanup()); }); -}); -test('Path traversal out of scope directory must not be possible', async () => { - await expect(filesController.get('/')).resolves.toBeNull(); - await expect(filesController.write('/', new ArrayBuffer(1))).rejects.toThrowError( - "EISDIR: illegal operation on a directory, open '/home/userData/appDir/app/vaultDir'", - ); + beforeEach(() => { + vol.reset(); + }); - // Just to ensure we can read available files - vol.writeFileSync('/home/userData/appDir/app/vaultDir/secret.txt', 'Secret data'); - await expect(filesController.get('./secret.txt')).resolves.toBeInstanceOf( - ArrayBuffer, - ); + test('One file can be deleted', async () => { + const files = new ElectronFilesController(storageApi, 'deletionTests'); + await createFiles(files, ['/bytes1', '/bytes2', '/bytes3']); - // Read file out of scoped directory - // Scope is defined by a client - vol.writeFileSync('/home/userData/appDir/secret.txt', 'Secret data'); - await expect(filesController.get('../secret.txt')).rejects.toThrowError( - 'Resolved path is out of root directory', - ); + await expect(files.list()).resolves.toContain('/bytes1'); - // All ops respects the limitation - await expect(filesController.delete(['../secret.txt'])).rejects.toThrowError( - 'Resolved path is out of root directory', - ); - await expect( - filesController.write('../secret.txt', new Uint8Array().buffer), - ).rejects.toThrowError('Resolved path is out of root directory'); - - // Read file out of root directory - vol.writeFileSync('/home/userData/secret.txt', 'Secret data'); - await expect(filesController.get('../../secret.txt')).rejects.toThrowError( - 'Resolved path is out of root directory', - ); -}); + await files.delete(['bytes1']); + await expect(files.list()).resolves.not.toContain('/bytes1'); + }); -test('When storage is disposed, any ops in controller throws error', async () => { - // Clean handler - storageHandlerCleanup(); - await expect(filesController.list()).rejects.toThrow( - 'No handler registered for storage.list', - ); - await expect(filesController.get('/')).rejects.toThrow( - 'No handler registered for storage.get', - ); - await expect(filesController.write('/', new ArrayBuffer(1))).rejects.toThrow( - 'No handler registered for storage.upload', - ); - await expect(filesController.delete(['/'])).rejects.toThrow( - 'No handler registered for storage.delete', - ); + test('Few files can be deleted', async () => { + const files = new ElectronFilesController(storageApi, 'deletionTests'); + await createFiles(files, ['/bytes1', '/bytes2', '/bytes3']); - // Setup handler - const storageHandlerCleanup2 = enableStorage(); - await expect(filesController.list()).resolves.toContainEqual('/test.txt'); + await expect(files.list()).resolves.toContain('/bytes2'); + await expect(files.list()).resolves.toContain('/bytes3'); - // Clear handler again - storageHandlerCleanup2(); - await expect(filesController.list()).rejects.toThrow( - 'No handler registered for storage.list', - ); + await files.delete(['bytes2', '/bytes3']); + + await expect(files.list()).resolves.not.toContain('/bytes2'); + await expect(files.list()).resolves.not.toContain('/bytes3'); + }); + + test('Directory deletion deletes all its content', async () => { + const files = new ElectronFilesController(storageApi, 'deletionTests'); + await createFiles(files, ['/foo/bytes1', '/foo/bar/bytes1', '/foo/bar/bytes2']); + + await expect(files.list()).resolves.toContain('/foo/bar/bytes1'); + await expect(files.list()).resolves.toContain('/foo/bar/bytes2'); + + await files.delete(['/foo/bar']); + await expect(files.list()).resolves.not.toContain('/foo/bar/bytes1'); + await expect(files.list()).resolves.not.toContain('/foo/bar/bytes2'); + + await expect(files.list()).resolves.toContain('/foo/bytes1'); + }); + + test('Root directory may be deleted', async () => { + const files = new ElectronFilesController(storageApi, 'deletionTests'); + await createFiles(files, ['/foo/bytes1', '/foo/bar/bytes1', '/foo/bar/bytes2']); + + await expect(files.list()).resolves.toHaveLength(3); + + await files.delete(['/']); + await expect(files.list()).resolves.toHaveLength(0); + }); }); diff --git a/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap b/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap index 825302c5f..8d91b3361 100644 --- a/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap +++ b/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap @@ -1,26 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Check files content > Files content 1`] = ` -[ - { - "content": "File text content", - "path": "/test.txt", - }, - { - "content": "File text content", - "path": "/foo/bar/baz/test.txt", - }, -] -`; - -exports[`Write files > Controller files list after writing 1`] = ` +exports[`Basic ops > File uploading persist data on disk > Controller files list after writing 1`] = ` [ "/test.txt", "/foo/bar/baz/test.txt", ] `; -exports[`Write files > FS structure after writing 1`] = ` +exports[`Basic ops > File uploading persist data on disk > FS structure after writing 1`] = ` [ "/home/userData/appDir/app", "/home/userData/appDir", @@ -34,3 +21,16 @@ exports[`Write files > FS structure after writing 1`] = ` "/home/userData/appDir/app/vaultDir", ] `; + +exports[`Basic ops > File uploading persist data on disk > Files content 1`] = ` +[ + { + "content": "File text content", + "path": "/test.txt", + }, + { + "content": "File text content", + "path": "/foo/bar/baz/test.txt", + }, +] +`; From 6aebf4928397a26addc96366158a1fa760968245 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 30 Sep 2026 21:36:12 +0300 Subject: [PATCH 05/37] perf: upload file by chunks --- .../src/electron/requests/storage/index.ts | 5 +- .../app/src/electron/requests/storage/main.ts | 67 +++++++++++++++++-- .../src/electron/requests/storage/renderer.ts | 20 +++++- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/packages/app/src/electron/requests/storage/index.ts b/packages/app/src/electron/requests/storage/index.ts index 59069ee94..8a4811f81 100644 --- a/packages/app/src/electron/requests/storage/index.ts +++ b/packages/app/src/electron/requests/storage/index.ts @@ -1,7 +1,10 @@ import { createChannel } from '../../utils/ipc'; export type StorageChannelAPI = { - upload: (id: string, buffer: ArrayBuffer, subdir: string) => Promise; + createUploadSession: (fileId: string, subdir: string) => Promise; + uploadChunk: (sessionId: string, buffer: ArrayBuffer) => Promise; + commitUpload: (sessionId: string) => Promise; + get: (id: string, subdir: string) => Promise; delete: (ids: string[], subdir: string) => Promise; list: (subdir: string) => Promise; diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 08c085942..1a6740744 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -2,6 +2,7 @@ import { IpcMainInvokeEvent } from 'electron'; import { existsSync, statSync } from 'fs'; import path from 'path'; import recursive from 'recursive-readdir'; +import { joinBuffers } from '@core/encryption/utils/buffers'; import { ApiToHandlers } from '@electron/utils/ipc'; import { recoveryAtomicFile, writeFileAtomic } from '@utils/files'; @@ -15,13 +16,16 @@ export const createStorageBackend = () => { const getScopedPath = (subdir: string | undefined, path?: string) => path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); - return { - async upload({ req: [id, buffer, subdir] }) { - const filePath = getScopedPath(subdir, id); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFileAtomic(filePath, Buffer.from(new Uint8Array(buffer))); - }, + let uploadId = 0; + const uploadSessions = new Map< + string, + { + path: string; + buffer: ArrayBuffer[]; + } + >(); + return { async get({ req: [id, subdir] }) { const filePath = getScopedPath(subdir, id); @@ -58,6 +62,57 @@ export const createStorageBackend = () => { path.slice(filesDir.length), ); }, + + createUploadSession: async ({ + req: [fileId, subdir], + }: { + req: [id: string, subdir: string]; + ctx: Electron.IpcMainInvokeEvent; + }): Promise => { + // TODO: ensure only one instance can write file + const resolvedPath = getScopedPath(subdir, fileId); + + const sessionId = String(++uploadId); + + uploadSessions.set(sessionId, { + path: resolvedPath, + buffer: [], + }); + + return sessionId; + }, + + uploadChunk: async function ({ + req: [sessionId, buffer], + }: { + req: [id: string, buffer: ArrayBuffer]; + ctx: Electron.IpcMainInvokeEvent; + }) { + const session = uploadSessions.get(sessionId); + if (!session) throw new Error(`No session found with id ${sessionId}`); + + session.buffer.push(buffer); + }, + + commitUpload: async function ({ + req: [sessionId], + }: { + req: [id: string]; + ctx: Electron.IpcMainInvokeEvent; + }) { + const session = uploadSessions.get(sessionId); + if (!session) throw new Error(`No session found with id ${sessionId}`); + + const { path: filePath, buffer } = session; + + await mkdir(path.dirname(filePath), { recursive: true }); + + console.time('Convert buffer'); + const nodeBuffer = Buffer.from(joinBuffers(buffer)); + console.timeEnd('Convert buffer'); + + await writeFileAtomic(filePath, nodeBuffer); + }, } satisfies ApiToHandlers; }; diff --git a/packages/app/src/electron/requests/storage/renderer.ts b/packages/app/src/electron/requests/storage/renderer.ts index 0610d348f..6a61c10bd 100644 --- a/packages/app/src/electron/requests/storage/renderer.ts +++ b/packages/app/src/electron/requests/storage/renderer.ts @@ -1,4 +1,5 @@ import { IEncryptionController } from '@core/encryption'; +import { BufferCursor } from '@core/encryption/utils/bytes/BufferCursor'; import { IFilesStorage } from '@core/features/files'; import { ipcRendererFetcher } from '../../utils/ipc/ipcRendererFetcher'; @@ -13,6 +14,7 @@ export class ElectronFilesController implements IFilesStorage { private readonly storageApi: StorageChannelAPI, private readonly subdirectory: string, private readonly encryption?: IEncryptionController, + private readonly config: { chunkSize?: number } = {}, ) { this.subdirectory = subdirectory; this.encryption = encryption; @@ -23,8 +25,22 @@ export class ElectronFilesController implements IFilesStorage { ? await this.encryption.encrypt(buffer) : buffer; - // Absolute file name with no slash - return this.storageApi.upload(filename, encryptedBuffer, this.subdirectory); + const sessionId = await this.storageApi.createUploadSession( + filename, + this.subdirectory, + ); + + const bufferCursor = new BufferCursor(encryptedBuffer); + while (bufferCursor.getRemainingBytes() > 0) { + const slice = bufferCursor.readBytes( + 1024 ** 2 * (this.config.chunkSize ?? 5), + ); + if (!slice) throw new Error('Unexpected end of file'); + + await this.storageApi.uploadChunk(sessionId, slice.slice(0).buffer); + } + + await this.storageApi.commitUpload(sessionId); } public async get(id: string) { From ba4b03923de2feaf432de649b6e1728bdff235e8 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Wed, 30 Sep 2026 22:31:55 +0300 Subject: [PATCH 06/37] chore: ensure only one session per file --- .../app/src/electron/requests/storage/main.ts | 30 ++++++- .../requests/storage/storage-backend.test.ts | 79 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/electron/requests/storage/storage-backend.test.ts diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 1a6740744..4ff7db11a 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -17,11 +17,14 @@ export const createStorageBackend = () => { path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); let uploadId = 0; + const pathUploadSessions = new Map(); const uploadSessions = new Map< string, { path: string; + // TODO: write buffers instantly to a file buffer: ArrayBuffer[]; + error?: unknown; } >(); @@ -69,11 +72,22 @@ export const createStorageBackend = () => { req: [id: string, subdir: string]; ctx: Electron.IpcMainInvokeEvent; }): Promise => { - // TODO: ensure only one instance can write file const resolvedPath = getScopedPath(subdir, fileId); - const sessionId = String(++uploadId); + // Cancel previous session + const previousSessionId = pathUploadSessions.get(resolvedPath); + if (previousSessionId !== undefined) { + // TODO: schedule deletion by timeout for case the session will not be accessed + const session = uploadSessions.get(previousSessionId); + if (session) { + session.error = new Error('Another session is started'); + session.buffer = []; + } + } + // Start new session + const sessionId = String(++uploadId); + pathUploadSessions.set(resolvedPath, sessionId); uploadSessions.set(sessionId, { path: resolvedPath, buffer: [], @@ -91,6 +105,9 @@ export const createStorageBackend = () => { const session = uploadSessions.get(sessionId); if (!session) throw new Error(`No session found with id ${sessionId}`); + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (session.error !== undefined) throw session.error; + session.buffer.push(buffer); }, @@ -103,6 +120,9 @@ export const createStorageBackend = () => { const session = uploadSessions.get(sessionId); if (!session) throw new Error(`No session found with id ${sessionId}`); + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (session.error !== undefined) throw session.error; + const { path: filePath, buffer } = session; await mkdir(path.dirname(filePath), { recursive: true }); @@ -111,7 +131,11 @@ export const createStorageBackend = () => { const nodeBuffer = Buffer.from(joinBuffers(buffer)); console.timeEnd('Convert buffer'); - await writeFileAtomic(filePath, nodeBuffer); + try { + await writeFileAtomic(filePath, nodeBuffer); + } finally { + uploadSessions.delete(sessionId); + } }, } satisfies ApiToHandlers; }; diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts new file mode 100644 index 000000000..fefac9c75 --- /dev/null +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -0,0 +1,79 @@ +import { vol } from 'memfs'; +import { getRandomBytes } from '@core/encryption/utils/random'; + +import { enableStorage } from './main'; +import { storageApi } from './renderer'; + +vi.mock('fs', () => vi.importActual('@mocks/fs')); +vi.mock('fs/promises', () => vi.importActual('@mocks/fs/promises')); +vi.mock('recursive-readdir', () => vi.importActual('@mocks/recursive-readdir')); +vi.mock('electron', () => vi.importActual('@mocks/electron')); + +describe('Upload sessions', () => { + let cleanups = [] as (() => void)[]; + beforeEach(() => { + vol.reset(); + cleanups.push(enableStorage()); + }); + afterEach(() => { + cleanups.forEach((cleanup) => cleanup()); + cleanups = []; + }); + + test('File can be loaded with session', async () => { + const session = await storageApi.createUploadSession('filename', '/foo/bar'); + + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + + await storageApi.commitUpload(session); + + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename'), + ).toHaveLength(1024 * 3); + }); + + test('Creation of new session for the same path must cancel previous session', async () => { + const session1 = await storageApi.createUploadSession('filename', '/foo/bar'); + await storageApi.uploadChunk(session1, getRandomBytes(1024).buffer); + + const session2 = await storageApi.createUploadSession('filename', '/foo/bar'); + await expect( + storageApi.uploadChunk(session1, getRandomBytes(1024).buffer), + ).rejects.toThrow('Another session is started'); + await expect( + storageApi.uploadChunk(session2, getRandomBytes(1024).buffer), + ).resolves.not.toThrow(); + + await expect(storageApi.commitUpload(session1)).rejects.toThrow( + 'Another session is started', + ); + await expect(storageApi.commitUpload(session2)).resolves.not.toThrow(); + + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename'), + ).toHaveLength(1024); + }); + + test('Many sessions may exists in parallel', async () => { + const session1 = await storageApi.createUploadSession('filename1', '/foo/bar'); + const session2 = await storageApi.createUploadSession('filename2', '/foo/bar'); + + await storageApi.uploadChunk(session1, getRandomBytes(1024).buffer); + await storageApi.uploadChunk(session2, getRandomBytes(1024).buffer); + + await storageApi.uploadChunk(session1, getRandomBytes(1024).buffer); + await storageApi.uploadChunk(session2, getRandomBytes(1024).buffer); + + await expect(storageApi.commitUpload(session1)).resolves.not.toThrow(); + await expect(storageApi.commitUpload(session2)).resolves.not.toThrow(); + + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename1'), + ).toHaveLength(1024 * 2); + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename2'), + ).toHaveLength(1024 * 2); + }); +}); From d89b55813086095a64d352baecb44578e34b5f84 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 00:05:10 +0300 Subject: [PATCH 07/37] refactor: save chunks in fs with no buffering --- .../app/src/electron/requests/storage/main.ts | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 4ff7db11a..39a8a76a7 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -1,10 +1,17 @@ import { IpcMainInvokeEvent } from 'electron'; -import { existsSync, statSync } from 'fs'; +import { once } from 'events'; +import { + createWriteStream, + existsSync, + renameSync, + rmSync, + statSync, + WriteStream, +} from 'fs'; import path from 'path'; import recursive from 'recursive-readdir'; -import { joinBuffers } from '@core/encryption/utils/buffers'; import { ApiToHandlers } from '@electron/utils/ipc'; -import { recoveryAtomicFile, writeFileAtomic } from '@utils/files'; +import { recoveryAtomicFile } from '@utils/files'; import { getUserDataPath, joinPath } from '../../utils/files'; import { ipcMainHandler } from '../../utils/ipc/ipcMainHandler'; @@ -12,7 +19,17 @@ import { ipcMainHandler } from '../../utils/ipc/ipcMainHandler'; import { mkdir, readFile, rm } from 'fs/promises'; import { storageChannel, StorageChannelAPI } from '.'; -export const createStorageBackend = () => { +type FilePaths = { + resolvedPath: string; + dirname: string; + filename: string; + tmp: string; + bkp: string; +}; + +export const createStorageBackend = ({ + tmpPrefix = '.tmp-fs', +}: { tmpPrefix?: string } = {}) => { const getScopedPath = (subdir: string | undefined, path?: string) => path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); @@ -21,13 +38,38 @@ export const createStorageBackend = () => { const uploadSessions = new Map< string, { - path: string; - // TODO: write buffers instantly to a file - buffer: ArrayBuffer[]; + paths: FilePaths; + stream: WriteStream; error?: unknown; } >(); + const getFilePaths = (subdir: string, fileId: string) => { + const resolvedPath = getScopedPath(subdir, fileId); + const dirname = path.dirname(resolvedPath); + const filename = path.basename(resolvedPath); + + const tmp = path.resolve( + path.join(dirname, [tmpPrefix, 'tmp', filename].join('-')), + ); + if (!tmp.startsWith(dirname)) + throw new Error('Temp file path is out of allowed path'); + + const bkp = path.resolve( + path.join(dirname, [tmpPrefix, 'bkp', filename].join('-')), + ); + if (!bkp.startsWith(dirname)) + throw new Error('Backup file path is out of allowed path'); + + return { + resolvedPath, + dirname, + filename, + tmp, + bkp, + } satisfies FilePaths; + }; + return { async get({ req: [id, subdir] }) { const filePath = getScopedPath(subdir, id); @@ -72,7 +114,8 @@ export const createStorageBackend = () => { req: [id: string, subdir: string]; ctx: Electron.IpcMainInvokeEvent; }): Promise => { - const resolvedPath = getScopedPath(subdir, fileId); + const paths = getFilePaths(subdir, fileId); + const { resolvedPath } = paths; // Cancel previous session const previousSessionId = pathUploadSessions.get(resolvedPath); @@ -81,16 +124,24 @@ export const createStorageBackend = () => { const session = uploadSessions.get(previousSessionId); if (session) { session.error = new Error('Another session is started'); - session.buffer = []; + session.stream.close(); + await once(session.stream, 'close'); } } + // Create tmp file + await mkdir(paths.dirname, { recursive: true }); + + if (existsSync(paths.tmp)) rmSync(paths.tmp); + const stream = createWriteStream(paths.tmp); + await once(stream, 'open'); + // Start new session const sessionId = String(++uploadId); pathUploadSessions.set(resolvedPath, sessionId); uploadSessions.set(sessionId, { - path: resolvedPath, - buffer: [], + paths, + stream, }); return sessionId; @@ -108,7 +159,10 @@ export const createStorageBackend = () => { // eslint-disable-next-line @typescript-eslint/only-throw-error if (session.error !== undefined) throw session.error; - session.buffer.push(buffer); + const { stream } = session; + if (!stream.write(new Uint8Array(buffer))) { + await once(stream, 'drain'); + } }, commitUpload: async function ({ @@ -123,16 +177,27 @@ export const createStorageBackend = () => { // eslint-disable-next-line @typescript-eslint/only-throw-error if (session.error !== undefined) throw session.error; - const { path: filePath, buffer } = session; + const { paths, stream } = session; - await mkdir(path.dirname(filePath), { recursive: true }); + try { + // Finish stream + stream.end(); + await once(stream, 'finish'); - console.time('Convert buffer'); - const nodeBuffer = Buffer.from(joinBuffers(buffer)); - console.timeEnd('Convert buffer'); + // Make sure file is uploaded + if (!existsSync(paths.tmp)) + throw new Error('Temporary file is not found'); - try { - await writeFileAtomic(filePath, nodeBuffer); + // Remove backup file + if (existsSync(paths.bkp)) rmSync(paths.bkp); + + // Backup file + if (existsSync(paths.resolvedPath)) + renameSync(paths.resolvedPath, paths.bkp); + + // Rename temp file + renameSync(paths.tmp, paths.resolvedPath); + if (existsSync(paths.bkp)) rmSync(paths.bkp); } finally { uploadSessions.delete(sessionId); } From ad06a05be8e3e9f196f9b4c365963dd1b66b3fd5 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 13:30:09 +0300 Subject: [PATCH 08/37] chore: hide tmp files --- .../app/src/electron/requests/storage/main.ts | 22 ++++++++++----- .../requests/storage/storage-backend.test.ts | 28 +++++++++++++++---- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 39a8a76a7..a5f3d6be7 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -27,9 +27,11 @@ type FilePaths = { bkp: string; }; +type StorageBackendOptions = { tmpPrefix?: string }; + export const createStorageBackend = ({ tmpPrefix = '.tmp-fs', -}: { tmpPrefix?: string } = {}) => { +}: StorageBackendOptions = {}) => { const getScopedPath = (subdir: string | undefined, path?: string) => path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); @@ -102,10 +104,16 @@ export const createStorageBackend = ({ if (!existsSync(filesDir)) return []; const files = await recursive(filesDir); - return files.map((path) => - // Remove root path - path.slice(filesDir.length), - ); + return files + .values() + .map((path) => + // Remove root path + path.slice(filesDir.length), + ) + .filter((info) => + info.split('/').every((segment) => !segment.startsWith(tmpPrefix)), + ) + .toArray(); }, createUploadSession: async ({ @@ -205,5 +213,5 @@ export const createStorageBackend = ({ } satisfies ApiToHandlers; }; -export const enableStorage = () => - storageChannel.server(ipcMainHandler, createStorageBackend()); +export const enableStorage = (options: StorageBackendOptions = {}) => + storageChannel.server(ipcMainHandler, createStorageBackend(options)); diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index fefac9c75..921777812 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -9,18 +9,15 @@ vi.mock('fs/promises', () => vi.importActual('@mocks/fs/promises')); vi.mock('recursive-readdir', () => vi.importActual('@mocks/recursive-readdir')); vi.mock('electron', () => vi.importActual('@mocks/electron')); +// TODO: add cases for rewrite, restore from backup, cleanup for tmp file describe('Upload sessions', () => { - let cleanups = [] as (() => void)[]; beforeEach(() => { vol.reset(); - cleanups.push(enableStorage()); - }); - afterEach(() => { - cleanups.forEach((cleanup) => cleanup()); - cleanups = []; }); test('File can be loaded with session', async () => { + onTestFinished(enableStorage()); + const session = await storageApi.createUploadSession('filename', '/foo/bar'); await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); @@ -34,7 +31,24 @@ describe('Upload sessions', () => { ).toHaveLength(1024 * 3); }); + test('Temporary files is not listed', async () => { + onTestFinished(enableStorage({ tmpPrefix: '.tmp' })); + + // Start uploading session + const session = await storageApi.createUploadSession('filename', '/foo/bar'); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + + // Temp file is present in FS + expect(vol.readdirSync('/', { recursive: true })).toContainEqual( + expect.stringMatching(/\.tmp/), + ); + + await expect(storageApi.list('/')).resolves.toEqual([]); + }); + test('Creation of new session for the same path must cancel previous session', async () => { + onTestFinished(enableStorage()); + const session1 = await storageApi.createUploadSession('filename', '/foo/bar'); await storageApi.uploadChunk(session1, getRandomBytes(1024).buffer); @@ -57,6 +71,8 @@ describe('Upload sessions', () => { }); test('Many sessions may exists in parallel', async () => { + onTestFinished(enableStorage()); + const session1 = await storageApi.createUploadSession('filename1', '/foo/bar'); const session2 = await storageApi.createUploadSession('filename2', '/foo/bar'); From a28af51e33ead14c7cfcb16368cf11fea495c879 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 13:39:03 +0300 Subject: [PATCH 09/37] chore: limit filenames to create --- .../app/src/electron/requests/storage/main.ts | 10 ++-- .../requests/storage/storage-backend.test.ts | 48 ++++++++++++++----- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index a5f3d6be7..d57f1bec2 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -35,6 +35,9 @@ export const createStorageBackend = ({ const getScopedPath = (subdir: string | undefined, path?: string) => path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); + const isAllowedPath = (filename: string) => + filename.split('/').every((segment) => !segment.startsWith(tmpPrefix)); + let uploadId = 0; const pathUploadSessions = new Map(); const uploadSessions = new Map< @@ -110,9 +113,7 @@ export const createStorageBackend = ({ // Remove root path path.slice(filesDir.length), ) - .filter((info) => - info.split('/').every((segment) => !segment.startsWith(tmpPrefix)), - ) + .filter(isAllowedPath) .toArray(); }, @@ -122,6 +123,9 @@ export const createStorageBackend = ({ req: [id: string, subdir: string]; ctx: Electron.IpcMainInvokeEvent; }): Promise => { + if (!isAllowedPath(fileId)) + throw new Error(`File name must not start from "${tmpPrefix}"`); + const paths = getFilePaths(subdir, fileId); const { resolvedPath } = paths; diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 921777812..5591a9875 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -9,26 +9,25 @@ vi.mock('fs/promises', () => vi.importActual('@mocks/fs/promises')); vi.mock('recursive-readdir', () => vi.importActual('@mocks/recursive-readdir')); vi.mock('electron', () => vi.importActual('@mocks/electron')); -// TODO: add cases for rewrite, restore from backup, cleanup for tmp file -describe('Upload sessions', () => { +describe('Special names', () => { beforeEach(() => { vol.reset(); }); - test('File can be loaded with session', async () => { - onTestFinished(enableStorage()); - - const session = await storageApi.createUploadSession('filename', '/foo/bar'); + test('File name with special prefix cannot be created', async () => { + onTestFinished(enableStorage({ tmpPrefix: '.tmp' })); - await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); - await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); - await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await expect(storageApi.createUploadSession('.tmp', '/foo/bar')).rejects.toThrow( + 'File name must not start from ".tmp"', + ); - await storageApi.commitUpload(session); + await expect( + storageApi.createUploadSession('foo/bar/.tmp', '/foo/bar'), + ).rejects.toThrow('File name must not start from ".tmp"'); - expect( - vol.readFileSync('/home/userData/appDir/app/foo/bar/filename'), - ).toHaveLength(1024 * 3); + await expect( + storageApi.createUploadSession('foo/bar/.tmp-bkp', '/foo/bar'), + ).rejects.toThrow('File name must not start from ".tmp"'); }); test('Temporary files is not listed', async () => { @@ -45,6 +44,29 @@ describe('Upload sessions', () => { await expect(storageApi.list('/')).resolves.toEqual([]); }); +}); + +// TODO: add cases for rewrite, restore from backup, cleanup for tmp file +describe('Upload sessions', () => { + beforeEach(() => { + vol.reset(); + }); + + test('File can be loaded with session', async () => { + onTestFinished(enableStorage()); + + const session = await storageApi.createUploadSession('filename', '/foo/bar'); + + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + + await storageApi.commitUpload(session); + + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename'), + ).toHaveLength(1024 * 3); + }); test('Creation of new session for the same path must cancel previous session', async () => { onTestFinished(enableStorage()); From e848f74e5ae1bf19de4b5f39ef47eca1095432ca Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 13:50:06 +0300 Subject: [PATCH 10/37] chore: ignore special files --- .../app/src/electron/requests/storage/main.ts | 16 +++++++++++----- .../requests/storage/storage-backend.test.ts | 15 ++++++++++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index d57f1bec2..6f60e4b11 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -76,8 +76,11 @@ export const createStorageBackend = ({ }; return { - async get({ req: [id, subdir] }) { - const filePath = getScopedPath(subdir, id); + async get({ req: [fileId, subdir] }) { + // Do not return special files + if (!isAllowedPath(fileId)) return null; + + const filePath = getScopedPath(subdir, fileId); recoveryAtomicFile(filePath); @@ -87,9 +90,12 @@ export const createStorageBackend = ({ return new Uint8Array(buffer).buffer; }, - async delete({ req: [ids, subdir] }) { - for (const id of ids) { - const filePath = getScopedPath(subdir, id); + async delete({ req: [fileIds, subdir] }) { + for (const fileId of fileIds) { + // Skip special files + if (!isAllowedPath(fileId)) continue; + + const filePath = getScopedPath(subdir, fileId); if (!existsSync(filePath)) { console.debug('Not found file', filePath); diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 5591a9875..b459b7bce 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -30,7 +30,7 @@ describe('Special names', () => { ).rejects.toThrow('File name must not start from ".tmp"'); }); - test('Temporary files is not listed', async () => { + test('Files with special name is unavailable', async () => { onTestFinished(enableStorage({ tmpPrefix: '.tmp' })); // Start uploading session @@ -38,11 +38,20 @@ describe('Special names', () => { await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); // Temp file is present in FS - expect(vol.readdirSync('/', { recursive: true })).toContainEqual( - expect.stringMatching(/\.tmp/), + expect(vol.readdirSync('/', { recursive: true })).toContain( + '/home/userData/appDir/app/foo/bar/.tmp-tmp-filename', ); await expect(storageApi.list('/')).resolves.toEqual([]); + await expect(storageApi.get('.tmp-tmp-filename', '/foo/bar')).resolves.toBe(null); + + // Deletion does not work + await expect( + storageApi.delete(['.tmp-tmp-filename'], '/foo/bar'), + ).resolves.toBeUndefined(); + expect(vol.readdirSync('/', { recursive: true })).toContain( + '/home/userData/appDir/app/foo/bar/.tmp-tmp-filename', + ); }); }); From d92d2c04ddad8ffcaef10751d8b64e7888a1198e Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 13:58:29 +0300 Subject: [PATCH 11/37] test: write cases --- .../requests/storage/storage-backend.test.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index b459b7bce..e76dd361b 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -55,7 +55,7 @@ describe('Special names', () => { }); }); -// TODO: add cases for rewrite, restore from backup, cleanup for tmp file +// TODO: add cases for restore from backup, cleanup for tmp file describe('Upload sessions', () => { beforeEach(() => { vol.reset(); @@ -77,6 +77,49 @@ describe('Upload sessions', () => { ).toHaveLength(1024 * 3); }); + test('Session cannot be re-used', async () => { + onTestFinished(enableStorage()); + + const session = await storageApi.createUploadSession('filename', '/foo/bar'); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await storageApi.commitUpload(session); + + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename'), + ).toHaveLength(1024); + + await expect( + storageApi.uploadChunk(session, getRandomBytes(1024).buffer), + ).rejects.toThrow('No session found'); + await expect(storageApi.commitUpload(session)).rejects.toThrow( + 'No session found', + ); + }); + + test('File can be changed', async () => { + onTestFinished(enableStorage()); + + // Write + const session = await storageApi.createUploadSession('filename', '/foo/bar'); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); + await storageApi.commitUpload(session); + + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename'), + ).toHaveLength(1024 * 2); + + // Write + const session2 = await storageApi.createUploadSession('filename', '/foo/bar'); + await storageApi.uploadChunk(session2, getRandomBytes(100).buffer); + await storageApi.uploadChunk(session2, getRandomBytes(100).buffer); + await storageApi.commitUpload(session2); + + expect( + vol.readFileSync('/home/userData/appDir/app/foo/bar/filename'), + ).toHaveLength(100 * 2); + }); + test('Creation of new session for the same path must cancel previous session', async () => { onTestFinished(enableStorage()); From 3c72cf79d40aac0cf08d8b5f53c2a8ea6780f3d3 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 14:33:56 +0300 Subject: [PATCH 12/37] chore: restore backup file --- .../app/src/electron/requests/storage/main.ts | 51 ++++++++++++------- .../requests/storage/storage-backend.test.ts | 23 +++++++++ 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 6f60e4b11..58315afc6 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -11,7 +11,6 @@ import { import path from 'path'; import recursive from 'recursive-readdir'; import { ApiToHandlers } from '@electron/utils/ipc'; -import { recoveryAtomicFile } from '@utils/files'; import { getUserDataPath, joinPath } from '../../utils/files'; import { ipcMainHandler } from '../../utils/ipc/ipcMainHandler'; @@ -35,20 +34,6 @@ export const createStorageBackend = ({ const getScopedPath = (subdir: string | undefined, path?: string) => path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); - const isAllowedPath = (filename: string) => - filename.split('/').every((segment) => !segment.startsWith(tmpPrefix)); - - let uploadId = 0; - const pathUploadSessions = new Map(); - const uploadSessions = new Map< - string, - { - paths: FilePaths; - stream: WriteStream; - error?: unknown; - } - >(); - const getFilePaths = (subdir: string, fileId: string) => { const resolvedPath = getScopedPath(subdir, fileId); const dirname = path.dirname(resolvedPath); @@ -75,18 +60,41 @@ export const createStorageBackend = ({ } satisfies FilePaths; }; + const isAllowedPath = (filename: string) => + filename.split('/').every((segment) => !segment.startsWith(tmpPrefix)); + + let uploadId = 0; + const pathUploadSessions = new Map(); + const uploadSessions = new Map< + string, + { + paths: FilePaths; + stream: WriteStream; + error?: unknown; + } + >(); + return { async get({ req: [fileId, subdir] }) { // Do not return special files if (!isAllowedPath(fileId)) return null; - const filePath = getScopedPath(subdir, fileId); + const { resolvedPath, bkp } = getFilePaths(subdir, fileId); - recoveryAtomicFile(filePath); + // Restore file + if ( + !existsSync(resolvedPath) && + !pathUploadSessions.has(resolvedPath) && + existsSync(bkp) && + statSync(bkp).isFile() + ) { + renameSync(bkp, resolvedPath); + } - if (!existsSync(filePath) || !statSync(filePath).isFile()) return null; + if (!existsSync(resolvedPath) || !statSync(resolvedPath).isFile()) + return null; - const buffer = await readFile(filePath); + const buffer = await readFile(resolvedPath); return new Uint8Array(buffer).buffer; }, @@ -218,6 +226,11 @@ export const createStorageBackend = ({ if (existsSync(paths.bkp)) rmSync(paths.bkp); } finally { uploadSessions.delete(sessionId); + + // Terminate session + const currentSessionId = pathUploadSessions.get(paths.resolvedPath); + if (currentSessionId === sessionId) + pathUploadSessions.delete(paths.resolvedPath); } }, } satisfies ApiToHandlers; diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index e76dd361b..41f43462b 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -167,3 +167,26 @@ describe('Upload sessions', () => { ).toHaveLength(1024 * 2); }); }); + +describe('File integrity', () => { + beforeEach(() => { + vol.reset(); + }); + + test('File can be restored from backup', async () => { + onTestFinished(enableStorage({ tmpPrefix: '.tmp' })); + + const fileContent = getRandomBytes(1024); + + // Create backup file + vol.mkdirSync('/home/userData/appDir/app/foo/bar', { recursive: true }); + vol.writeFileSync( + '/home/userData/appDir/app/foo/bar/.tmp-bkp-filename', + fileContent, + ); + + await expect(storageApi.get('filename', '/foo/bar')).resolves.toStrictEqual( + fileContent.buffer, + ); + }); +}); From c53615ebc7b3ece714edf6aff6da9777c6b51c65 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 14:34:45 +0300 Subject: [PATCH 13/37] chore: always restore --- packages/app/src/electron/requests/storage/main.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 58315afc6..36684b8f0 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -82,12 +82,7 @@ export const createStorageBackend = ({ const { resolvedPath, bkp } = getFilePaths(subdir, fileId); // Restore file - if ( - !existsSync(resolvedPath) && - !pathUploadSessions.has(resolvedPath) && - existsSync(bkp) && - statSync(bkp).isFile() - ) { + if (!existsSync(resolvedPath) && existsSync(bkp) && statSync(bkp).isFile()) { renameSync(bkp, resolvedPath); } From cc01f558d26ff26aacb85ffd0febe076f5559c35 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 14:54:43 +0300 Subject: [PATCH 14/37] chore: delete tmp files --- .../app/src/electron/requests/storage/main.ts | 23 +++++++++++++++---- .../requests/storage/storage-backend.test.ts | 22 ++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 36684b8f0..c1663c7be 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -98,15 +98,28 @@ export const createStorageBackend = ({ // Skip special files if (!isAllowedPath(fileId)) continue; - const filePath = getScopedPath(subdir, fileId); + const { resolvedPath, bkp, tmp } = getFilePaths(subdir, fileId); - if (!existsSync(filePath)) { - console.debug('Not found file', filePath); + if (!existsSync(resolvedPath)) { + console.debug('Not found file', resolvedPath); continue; } - await rm(filePath, { force: true, recursive: true }); - console.debug('Removed file', filePath); + if (statSync(resolvedPath).isDirectory()) { + // Recursive directory deletion + await rm(resolvedPath, { force: true, recursive: true }); + console.debug('Directory removed', resolvedPath); + } else { + // Delete file and its related files + const pathsToRemove = [resolvedPath, bkp]; + if (!pathUploadSessions.has(resolvedPath)) pathsToRemove.push(tmp); + + for (const path of pathsToRemove) { + if (!existsSync(path) || !statSync(path).isFile()) continue; + rmSync(path); + console.debug('File deleted', path); + } + } } }, diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 41f43462b..d8c8a56bd 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -189,4 +189,26 @@ describe('File integrity', () => { fileContent.buffer, ); }); + + test('File deletes with all related tmp files', async () => { + onTestFinished(enableStorage({ tmpPrefix: '.tmp' })); + + const fileContent = getRandomBytes(1024); + + // Create backup file + vol.mkdirSync('/home/userData/appDir/app/foo/bar', { recursive: true }); + vol.writeFileSync( + '/home/userData/appDir/app/foo/bar/.tmp-bkp-filename', + fileContent, + ); + vol.writeFileSync('/home/userData/appDir/app/foo/bar/filename', fileContent); + expect(vol.readdirSync('/', { recursive: true })).toContainEqual( + expect.stringContaining('filename'), + ); + + await storageApi.delete(['filename'], '/foo/bar'); + expect(vol.readdirSync('/', { recursive: true })).not.toContainEqual( + expect.stringContaining('filename'), + ); + }); }); From b1fc0e022183e906ed622488324466f9ad221059 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 15:02:01 +0300 Subject: [PATCH 15/37] chore: handle write in directory --- .../storage/ElectronFilesController.integration.test.ts | 2 +- packages/app/src/electron/requests/storage/main.ts | 6 +++++- .../src/electron/requests/storage/storage-backend.test.ts | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts index 43879bee1..27581f03f 100644 --- a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts +++ b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts @@ -194,7 +194,7 @@ describe('Path traversal isolation', () => { test('Attempt to write content in directory must throws error', async () => { const files = new ElectronFilesController(storageApi, 'vaultDir'); await expect(files.write('/', new ArrayBuffer(1))).rejects.toThrowError( - "EISDIR: illegal operation on a directory, open '/home/userData/appDir/app/vaultDir'", + 'Path is exist and it is not a file', ); }); diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index c1663c7be..5249c983b 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -151,6 +151,10 @@ export const createStorageBackend = ({ const paths = getFilePaths(subdir, fileId); const { resolvedPath } = paths; + // Prevent write into directory + if (existsSync(resolvedPath) && !statSync(resolvedPath).isFile()) + throw new Error(`Path is exist and it is not a file '${resolvedPath}'`); + // Cancel previous session const previousSessionId = pathUploadSessions.get(resolvedPath); if (previousSessionId !== undefined) { @@ -163,7 +167,7 @@ export const createStorageBackend = ({ } } - // Create tmp file + // Ensure directory await mkdir(paths.dirname, { recursive: true }); if (existsSync(paths.tmp)) rmSync(paths.tmp); diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index d8c8a56bd..44e86dc34 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -55,7 +55,6 @@ describe('Special names', () => { }); }); -// TODO: add cases for restore from backup, cleanup for tmp file describe('Upload sessions', () => { beforeEach(() => { vol.reset(); From 12a2aa6ee89394ba2ef3a87b238497b6106face4 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 15:08:56 +0300 Subject: [PATCH 16/37] test: schedule via timeout in test environment --- packages/app/src/core/database/ManagedDatabase.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/app/src/core/database/ManagedDatabase.ts b/packages/app/src/core/database/ManagedDatabase.ts index b66390da8..f53d1c013 100644 --- a/packages/app/src/core/database/ManagedDatabase.ts +++ b/packages/app/src/core/database/ManagedDatabase.ts @@ -44,6 +44,15 @@ export type Options = { }; }; +const requestRunOnIdle = (cb: () => void) => { + if (typeof globalThis.requestIdleCallback === 'function') { + globalThis.requestIdleCallback(cb); + return; + } + + setTimeout(cb, 0); +}; + /** * Class to manage database data synchronization */ @@ -63,7 +72,7 @@ export class ManagedDatabase implements IManagedDatabase { // Auto sync changes this.debouncedSync = debounce( () => { - requestIdleCallback(() => this.sync()); + requestRunOnIdle(() => this.sync()); }, { wait: sync.delay, From 1abfb5e5ad73bcf041a623f8597e4959a00dcea2 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 17:03:46 +0300 Subject: [PATCH 17/37] fix: remove backup files even if file is missed --- .../app/src/electron/requests/storage/main.ts | 7 +------ .../requests/storage/storage-backend.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 5249c983b..0f5245902 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -100,12 +100,7 @@ export const createStorageBackend = ({ const { resolvedPath, bkp, tmp } = getFilePaths(subdir, fileId); - if (!existsSync(resolvedPath)) { - console.debug('Not found file', resolvedPath); - continue; - } - - if (statSync(resolvedPath).isDirectory()) { + if (existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) { // Recursive directory deletion await rm(resolvedPath, { force: true, recursive: true }); console.debug('Directory removed', resolvedPath); diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 44e86dc34..30e34bc34 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -210,4 +210,20 @@ describe('File integrity', () => { expect.stringContaining('filename'), ); }); + + test('Deleting a file removes its backup even when the target is missing', async () => { + onTestFinished(enableStorage({ tmpPrefix: '.tmp' })); + + const root = '/home/userData/appDir/app/foo/bar'; + const backupPath = `${root}/.tmp-bkp-note`; + vol.mkdirSync(root, { recursive: true }); + vol.writeFileSync(backupPath, 'deleted content'); + expect(vol.existsSync(`${root}/note`)).toBe(false); + expect(vol.existsSync(backupPath)).toBe(true); + + await storageApi.delete(['note'], '/foo/bar'); + + expect(vol.existsSync(backupPath)).toBe(false); + await expect(storageApi.get('note', '/foo/bar')).resolves.toBe(null); + }); }); From e9bfd5295391b73625f2056393319f763f49a739 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Thu, 1 Oct 2026 17:33:04 +0300 Subject: [PATCH 18/37] fix: ensure rooted directory is under root --- ...lectronFilesController.integration.test.ts | 37 +++++++++++++++++++ packages/app/src/electron/utils/files.ts | 9 ++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts index 27581f03f..7f1919370 100644 --- a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts +++ b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts @@ -191,6 +191,43 @@ describe('Path traversal isolation', () => { ); }); + test('Client cannot read from a sibling directory with a matching root prefix', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + vol.mkdirSync('/home/userData/appDir/app/vaultDir2', { recursive: true }); + vol.writeFileSync( + '/home/userData/appDir/app/vaultDir2/secret.txt', + 'sibling secret', + ); + + await expect(files.get('../vaultDir2/secret.txt')).rejects.toThrow( + 'Resolved path is out of root directory', + ); + }); + + test('Client cannot overwrite a file in a sibling directory with a matching root prefix', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + const siblingSecretPath = '/home/userData/appDir/app/vaultDir2/secret.txt'; + vol.mkdirSync('/home/userData/appDir/app/vaultDir2', { recursive: true }); + vol.writeFileSync(siblingSecretPath, 'sibling secret'); + + await expect( + files.write('../vaultDir2/secret.txt', getBufferFromText('overwritten')), + ).rejects.toThrow('Resolved path is out of root directory'); + expect(vol.readFileSync(siblingSecretPath, 'utf8')).toBe('sibling secret'); + }); + + test('Client cannot delete a file in a sibling directory with a matching root prefix', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + const siblingSecretPath = '/home/userData/appDir/app/vaultDir2/secret.txt'; + vol.mkdirSync('/home/userData/appDir/app/vaultDir2', { recursive: true }); + vol.writeFileSync(siblingSecretPath, 'sibling secret'); + + await expect(files.delete(['../vaultDir2/secret.txt'])).rejects.toThrow( + 'Resolved path is out of root directory', + ); + expect(vol.readFileSync(siblingSecretPath, 'utf8')).toBe('sibling secret'); + }); + test('Attempt to write content in directory must throws error', async () => { const files = new ElectronFilesController(storageApi, 'vaultDir'); await expect(files.write('/', new ArrayBuffer(1))).rejects.toThrowError( diff --git a/packages/app/src/electron/utils/files.ts b/packages/app/src/electron/utils/files.ts index 49c0e235a..d0563c612 100644 --- a/packages/app/src/electron/utils/files.ts +++ b/packages/app/src/electron/utils/files.ts @@ -3,6 +3,13 @@ import path from 'path'; import { isDevMode } from './app'; +const isRootedPath = (root: string, targetPath: string) => { + const pathSegments = targetPath.split(path.sep); + return root + .split(path.sep) + .every((rootSegment, index) => rootSegment === pathSegments[index]); +}; + /** * Safe join path segments and resolve it * @@ -10,7 +17,7 @@ import { isDevMode } from './app'; */ export const joinPath = (root: string, ...segments: string[]) => { const resolvedPath = path.resolve(path.join(root, ...segments)); - if (!resolvedPath.startsWith(root)) { + if (!isRootedPath(root, resolvedPath)) { throw new TypeError('Resolved path is out of root directory'); } From 5d402f3ae18694f9df8a263a58bbdf81c5b5327e Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 01:32:10 +0300 Subject: [PATCH 19/37] refactor: use classes to express files storage --- .../electron/requests/storage/FilesStorage.ts | 71 ++++++ .../requests/storage/PathsResolver.ts | 65 ++++++ .../requests/storage/UploadSession.ts | 100 ++++++++ .../requests/storage/UploadSessions.ts | 57 +++++ .../app/src/electron/requests/storage/main.ts | 214 ++---------------- .../requests/storage/storage-backend.test.ts | 6 +- 6 files changed, 312 insertions(+), 201 deletions(-) create mode 100644 packages/app/src/electron/requests/storage/FilesStorage.ts create mode 100644 packages/app/src/electron/requests/storage/PathsResolver.ts create mode 100644 packages/app/src/electron/requests/storage/UploadSession.ts create mode 100644 packages/app/src/electron/requests/storage/UploadSessions.ts diff --git a/packages/app/src/electron/requests/storage/FilesStorage.ts b/packages/app/src/electron/requests/storage/FilesStorage.ts new file mode 100644 index 000000000..a868b40d3 --- /dev/null +++ b/packages/app/src/electron/requests/storage/FilesStorage.ts @@ -0,0 +1,71 @@ +import { existsSync, renameSync, rmSync, statSync } from 'fs'; +import recursive from 'recursive-readdir'; + +import { readFile, rm } from 'fs/promises'; +import { PathsResolver } from './PathsResolver'; +import { UploadSessions } from './UploadSessions'; + +export class FilesStorage { + constructor( + private readonly paths: PathsResolver, + private readonly opSessions: UploadSessions, + ) {} + + async get(fileId: string, subdir: string) { + // Do not return special files + if (!this.paths.isAllowedPath(fileId)) return null; + + const { resolvedPath, bkp } = this.paths.getFilePaths(subdir, fileId); + + // Restore file + if (!existsSync(resolvedPath) && existsSync(bkp) && statSync(bkp).isFile()) { + renameSync(bkp, resolvedPath); + } + + if (!existsSync(resolvedPath) || !statSync(resolvedPath).isFile()) return null; + + const buffer = await readFile(resolvedPath); + return new Uint8Array(buffer).buffer; + } + + async delete(fileIds: string[], subdir: string) { + for (const fileId of fileIds) { + // Skip special files + if (!this.paths.isAllowedPath(fileId)) continue; + + const { resolvedPath, bkp, tmp } = this.paths.getFilePaths(subdir, fileId); + + if (existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) { + // Recursive directory deletion + await rm(resolvedPath, { force: true, recursive: true }); + console.debug('Directory removed', resolvedPath); + } else { + // Delete file and its related files + const pathsToRemove = [resolvedPath, bkp]; + if (!this.opSessions.getByFilename(resolvedPath)) pathsToRemove.push(tmp); + + for (const path of pathsToRemove) { + if (!existsSync(path) || !statSync(path).isFile()) continue; + rmSync(path); + console.debug('File deleted', path); + } + } + } + } + + async list(subdir: string) { + const filesDir = this.paths.getScopedPath(subdir); + + if (!existsSync(filesDir)) return []; + + const files = await recursive(filesDir); + return files + .values() + .map((path) => + // Remove root path + path.slice(filesDir.length), + ) + .filter((path) => this.paths.isAllowedPath(path)) + .toArray(); + } +} diff --git a/packages/app/src/electron/requests/storage/PathsResolver.ts b/packages/app/src/electron/requests/storage/PathsResolver.ts new file mode 100644 index 000000000..921884dce --- /dev/null +++ b/packages/app/src/electron/requests/storage/PathsResolver.ts @@ -0,0 +1,65 @@ +import path from 'node:path'; + +import { getUserDataPath, joinPath } from '@electron/utils/files'; + +export type StorageOptions = { tmpPrefix?: string }; + +export type FilePaths = { + resolvedPath: string; + dirname: string; + filename: string; + tmp: string; + bkp: string; +}; + +export class PathsResolver { + private readonly config; + constructor(config: StorageOptions = {}) { + this.config = { + tmpPrefix: '.tmp-fs', + ...config, + }; + } + + public getScopedPath(subdir: string | undefined, path?: string) { + return path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); + } + + public getFilePaths(subdir: string, fileId: string) { + const { tmpPrefix } = this.config; + + const resolvedPath = this.getScopedPath(subdir, fileId); + const dirname = path.dirname(resolvedPath); + const filename = path.basename(resolvedPath); + + const tmp = path.resolve( + path.join(dirname, [tmpPrefix, 'tmp', filename].join('-')), + ); + if (!tmp.startsWith(dirname)) + throw new Error('Temp file path is out of allowed path'); + + const bkp = path.resolve( + path.join(dirname, [tmpPrefix, 'bkp', filename].join('-')), + ); + if (!bkp.startsWith(dirname)) + throw new Error('Backup file path is out of allowed path'); + + return { + resolvedPath, + dirname, + filename, + tmp, + bkp, + } satisfies FilePaths; + } + + public isAllowedPath(filename: string) { + const { tmpPrefix } = this.config; + + return filename.split('/').every((segment) => !segment.startsWith(tmpPrefix)); + } + + public getPrefix() { + return this.config.tmpPrefix; + } +} diff --git a/packages/app/src/electron/requests/storage/UploadSession.ts b/packages/app/src/electron/requests/storage/UploadSession.ts new file mode 100644 index 000000000..454263097 --- /dev/null +++ b/packages/app/src/electron/requests/storage/UploadSession.ts @@ -0,0 +1,100 @@ +import { once } from 'node:events'; +import { + createWriteStream, + existsSync, + renameSync, + rmSync, + statSync, + WriteStream, +} from 'node:fs'; +import { mkdir } from 'node:fs/promises'; + +import { FilePaths } from './PathsResolver'; + +export class UploadSession { + constructor(private readonly paths: FilePaths) {} + + private stream?: WriteStream; + private isInitializationCalled = false; + private onCommit?: () => void; + public async init(onCommit?: () => void) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (this.error !== undefined) throw this.error; + if (this.stream) throw new Error('Session is already initialized'); + if (this.isInitializationCalled) throw new Error('Initialization is in progress'); + this.isInitializationCalled = true; + this.onCommit = onCommit; + + const { resolvedPath, dirname, tmp } = this.paths; + + // Prevent write into directory + if (existsSync(resolvedPath) && !statSync(resolvedPath).isFile()) + throw new Error(`Path is exist and it is not a file '${resolvedPath}'`); + + // Ensure directory + await mkdir(dirname, { recursive: true }); + + // Open stream + if (existsSync(tmp)) rmSync(tmp); + const stream = createWriteStream(tmp); + await once(stream, 'open'); + + this.stream = stream; + } + + private error?: unknown; + public async abort(error: unknown) { + if (this.error !== undefined) throw new Error('Already aborted'); + + // Set error immediately + this.error = error; + + // Close stream + if (this.stream) { + this.stream.close(); + await once(this.stream, 'close'); + } + } + + public async write(buffer: ArrayBuffer) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (this.error !== undefined) throw this.error; + + if (!this.stream) throw new Error('Session is not initialized yet'); + + if (!this.stream.write(new Uint8Array(buffer))) { + await once(this.stream, 'drain'); + } + } + + public async commit() { + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (this.error !== undefined) throw this.error; + + if (!this.stream) throw new Error('Session is not initialized yet'); + + const stream = this.stream; + const paths = this.paths; + + try { + // Finish stream + stream.end(); + await once(stream, 'finish'); + + // Make sure file is uploaded + if (!existsSync(paths.tmp)) throw new Error('Temporary file is not found'); + + // Remove backup file + if (existsSync(paths.bkp)) rmSync(paths.bkp); + + // Backup file + if (existsSync(paths.resolvedPath)) renameSync(paths.resolvedPath, paths.bkp); + + // Rename temp file + renameSync(paths.tmp, paths.resolvedPath); + if (existsSync(paths.bkp)) rmSync(paths.bkp); + } finally { + this.onCommit?.(); + } + } +} diff --git a/packages/app/src/electron/requests/storage/UploadSessions.ts b/packages/app/src/electron/requests/storage/UploadSessions.ts new file mode 100644 index 000000000..eac645ddd --- /dev/null +++ b/packages/app/src/electron/requests/storage/UploadSessions.ts @@ -0,0 +1,57 @@ +import { PathsResolver } from './PathsResolver'; +import { UploadSession } from './UploadSession'; + +export class UploadSessions { + private uploadId = 0; + private readonly pathUploadSessions = new Map(); + private readonly uploadSessions = new Map(); + + constructor(private readonly paths: PathsResolver) {} + + public async create(fileId: string, subdir: string) { + if (!this.paths.isAllowedPath(fileId)) + throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); + + const paths = this.paths.getFilePaths(subdir, fileId); + const { resolvedPath } = paths; + + // Cancel previous session + const previousSessionId = this.pathUploadSessions.get(resolvedPath); + if (previousSessionId !== undefined) { + // TODO: schedule deletion by timeout for case the session will not be accessed + const session = this.uploadSessions.get(previousSessionId); + if (session) { + await session.abort(new Error('Another session is started')); + } + } + + // Start new session + const sessionId = String(++this.uploadId); + + const session = new UploadSession(paths); + await session.init(() => { + this.uploadSessions.delete(sessionId); + + // Terminate session + const currentSessionId = this.pathUploadSessions.get(paths.resolvedPath); + if (currentSessionId === sessionId) + this.pathUploadSessions.delete(paths.resolvedPath); + }); + + this.pathUploadSessions.set(resolvedPath, sessionId); + this.uploadSessions.set(sessionId, session); + + return sessionId; + } + + public getByFilename(filename: string) { + const sessionId = this.pathUploadSessions.get(filename); + + if (sessionId === undefined) return null; + return this.uploadSessions.get(sessionId) ?? null; + } + + public getById(sessionId: string) { + return this.uploadSessions.get(sessionId) ?? null; + } +} diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 0f5245902..fcc8bc619 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -1,137 +1,29 @@ import { IpcMainInvokeEvent } from 'electron'; -import { once } from 'events'; -import { - createWriteStream, - existsSync, - renameSync, - rmSync, - statSync, - WriteStream, -} from 'fs'; -import path from 'path'; -import recursive from 'recursive-readdir'; import { ApiToHandlers } from '@electron/utils/ipc'; -import { getUserDataPath, joinPath } from '../../utils/files'; import { ipcMainHandler } from '../../utils/ipc/ipcMainHandler'; -import { mkdir, readFile, rm } from 'fs/promises'; +import { FilesStorage } from './FilesStorage'; +import { PathsResolver, StorageOptions } from './PathsResolver'; +import { UploadSessions } from './UploadSessions'; import { storageChannel, StorageChannelAPI } from '.'; -type FilePaths = { - resolvedPath: string; - dirname: string; - filename: string; - tmp: string; - bkp: string; -}; - -type StorageBackendOptions = { tmpPrefix?: string }; - -export const createStorageBackend = ({ - tmpPrefix = '.tmp-fs', -}: StorageBackendOptions = {}) => { - const getScopedPath = (subdir: string | undefined, path?: string) => - path ? joinPath(getUserDataPath(subdir), path) : getUserDataPath(subdir); - - const getFilePaths = (subdir: string, fileId: string) => { - const resolvedPath = getScopedPath(subdir, fileId); - const dirname = path.dirname(resolvedPath); - const filename = path.basename(resolvedPath); - - const tmp = path.resolve( - path.join(dirname, [tmpPrefix, 'tmp', filename].join('-')), - ); - if (!tmp.startsWith(dirname)) - throw new Error('Temp file path is out of allowed path'); - - const bkp = path.resolve( - path.join(dirname, [tmpPrefix, 'bkp', filename].join('-')), - ); - if (!bkp.startsWith(dirname)) - throw new Error('Backup file path is out of allowed path'); - - return { - resolvedPath, - dirname, - filename, - tmp, - bkp, - } satisfies FilePaths; - }; - - const isAllowedPath = (filename: string) => - filename.split('/').every((segment) => !segment.startsWith(tmpPrefix)); - - let uploadId = 0; - const pathUploadSessions = new Map(); - const uploadSessions = new Map< - string, - { - paths: FilePaths; - stream: WriteStream; - error?: unknown; - } - >(); +export const createStorageBackend = ({ tmpPrefix = '.tmp-fs' }: StorageOptions = {}) => { + const pathsResolver = new PathsResolver({ tmpPrefix }); + const uploadSessions = new UploadSessions(pathsResolver); + const storage = new FilesStorage(pathsResolver, uploadSessions); return { async get({ req: [fileId, subdir] }) { - // Do not return special files - if (!isAllowedPath(fileId)) return null; - - const { resolvedPath, bkp } = getFilePaths(subdir, fileId); - - // Restore file - if (!existsSync(resolvedPath) && existsSync(bkp) && statSync(bkp).isFile()) { - renameSync(bkp, resolvedPath); - } - - if (!existsSync(resolvedPath) || !statSync(resolvedPath).isFile()) - return null; - - const buffer = await readFile(resolvedPath); - return new Uint8Array(buffer).buffer; + return storage.get(fileId, subdir); }, async delete({ req: [fileIds, subdir] }) { - for (const fileId of fileIds) { - // Skip special files - if (!isAllowedPath(fileId)) continue; - - const { resolvedPath, bkp, tmp } = getFilePaths(subdir, fileId); - - if (existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) { - // Recursive directory deletion - await rm(resolvedPath, { force: true, recursive: true }); - console.debug('Directory removed', resolvedPath); - } else { - // Delete file and its related files - const pathsToRemove = [resolvedPath, bkp]; - if (!pathUploadSessions.has(resolvedPath)) pathsToRemove.push(tmp); - - for (const path of pathsToRemove) { - if (!existsSync(path) || !statSync(path).isFile()) continue; - rmSync(path); - console.debug('File deleted', path); - } - } - } + return storage.delete(fileIds, subdir); }, async list({ req: [subdir] }) { - const filesDir = getScopedPath(subdir); - - if (!existsSync(filesDir)) return []; - - const files = await recursive(filesDir); - return files - .values() - .map((path) => - // Remove root path - path.slice(filesDir.length), - ) - .filter(isAllowedPath) - .toArray(); + return storage.list(subdir); }, createUploadSession: async ({ @@ -140,44 +32,7 @@ export const createStorageBackend = ({ req: [id: string, subdir: string]; ctx: Electron.IpcMainInvokeEvent; }): Promise => { - if (!isAllowedPath(fileId)) - throw new Error(`File name must not start from "${tmpPrefix}"`); - - const paths = getFilePaths(subdir, fileId); - const { resolvedPath } = paths; - - // Prevent write into directory - if (existsSync(resolvedPath) && !statSync(resolvedPath).isFile()) - throw new Error(`Path is exist and it is not a file '${resolvedPath}'`); - - // Cancel previous session - const previousSessionId = pathUploadSessions.get(resolvedPath); - if (previousSessionId !== undefined) { - // TODO: schedule deletion by timeout for case the session will not be accessed - const session = uploadSessions.get(previousSessionId); - if (session) { - session.error = new Error('Another session is started'); - session.stream.close(); - await once(session.stream, 'close'); - } - } - - // Ensure directory - await mkdir(paths.dirname, { recursive: true }); - - if (existsSync(paths.tmp)) rmSync(paths.tmp); - const stream = createWriteStream(paths.tmp); - await once(stream, 'open'); - - // Start new session - const sessionId = String(++uploadId); - pathUploadSessions.set(resolvedPath, sessionId); - uploadSessions.set(sessionId, { - paths, - stream, - }); - - return sessionId; + return uploadSessions.create(fileId, subdir); }, uploadChunk: async function ({ @@ -186,16 +41,10 @@ export const createStorageBackend = ({ req: [id: string, buffer: ArrayBuffer]; ctx: Electron.IpcMainInvokeEvent; }) { - const session = uploadSessions.get(sessionId); + const session = uploadSessions.getById(sessionId); if (!session) throw new Error(`No session found with id ${sessionId}`); - // eslint-disable-next-line @typescript-eslint/only-throw-error - if (session.error !== undefined) throw session.error; - - const { stream } = session; - if (!stream.write(new Uint8Array(buffer))) { - await once(stream, 'drain'); - } + await session.write(buffer); }, commitUpload: async function ({ @@ -204,44 +53,13 @@ export const createStorageBackend = ({ req: [id: string]; ctx: Electron.IpcMainInvokeEvent; }) { - const session = uploadSessions.get(sessionId); + const session = uploadSessions.getById(sessionId); if (!session) throw new Error(`No session found with id ${sessionId}`); - // eslint-disable-next-line @typescript-eslint/only-throw-error - if (session.error !== undefined) throw session.error; - - const { paths, stream } = session; - - try { - // Finish stream - stream.end(); - await once(stream, 'finish'); - - // Make sure file is uploaded - if (!existsSync(paths.tmp)) - throw new Error('Temporary file is not found'); - - // Remove backup file - if (existsSync(paths.bkp)) rmSync(paths.bkp); - - // Backup file - if (existsSync(paths.resolvedPath)) - renameSync(paths.resolvedPath, paths.bkp); - - // Rename temp file - renameSync(paths.tmp, paths.resolvedPath); - if (existsSync(paths.bkp)) rmSync(paths.bkp); - } finally { - uploadSessions.delete(sessionId); - - // Terminate session - const currentSessionId = pathUploadSessions.get(paths.resolvedPath); - if (currentSessionId === sessionId) - pathUploadSessions.delete(paths.resolvedPath); - } + await session.commit(); }, } satisfies ApiToHandlers; }; -export const enableStorage = (options: StorageBackendOptions = {}) => +export const enableStorage = (options: StorageOptions = {}) => storageChannel.server(ipcMainHandler, createStorageBackend(options)); diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 30e34bc34..8992b44c0 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -18,16 +18,16 @@ describe('Special names', () => { onTestFinished(enableStorage({ tmpPrefix: '.tmp' })); await expect(storageApi.createUploadSession('.tmp', '/foo/bar')).rejects.toThrow( - 'File name must not start from ".tmp"', + "File name must not start from '.tmp'", ); await expect( storageApi.createUploadSession('foo/bar/.tmp', '/foo/bar'), - ).rejects.toThrow('File name must not start from ".tmp"'); + ).rejects.toThrow("File name must not start from '.tmp'"); await expect( storageApi.createUploadSession('foo/bar/.tmp-bkp', '/foo/bar'), - ).rejects.toThrow('File name must not start from ".tmp"'); + ).rejects.toThrow("File name must not start from '.tmp'"); }); test('Files with special name is unavailable', async () => { From 8725967fdea142eccbf1198fab2cd8846e688ca7 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 01:41:37 +0300 Subject: [PATCH 20/37] chore: check session is not aborted after async ops --- .../electron/requests/storage/UploadSession.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/app/src/electron/requests/storage/UploadSession.ts b/packages/app/src/electron/requests/storage/UploadSession.ts index 454263097..9ca64f101 100644 --- a/packages/app/src/electron/requests/storage/UploadSession.ts +++ b/packages/app/src/electron/requests/storage/UploadSession.ts @@ -18,8 +18,8 @@ export class UploadSession { private isInitializationCalled = false; private onCommit?: () => void; public async init(onCommit?: () => void) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - if (this.error !== undefined) throw this.error; + this.assertNotAborted(); + if (this.stream) throw new Error('Session is already initialized'); if (this.isInitializationCalled) throw new Error('Initialization is in progress'); this.isInitializationCalled = true; @@ -57,8 +57,7 @@ export class UploadSession { } public async write(buffer: ArrayBuffer) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - if (this.error !== undefined) throw this.error; + this.assertNotAborted(); if (!this.stream) throw new Error('Session is not initialized yet'); @@ -68,8 +67,7 @@ export class UploadSession { } public async commit() { - // eslint-disable-next-line @typescript-eslint/only-throw-error - if (this.error !== undefined) throw this.error; + this.assertNotAborted(); if (!this.stream) throw new Error('Session is not initialized yet'); @@ -80,6 +78,7 @@ export class UploadSession { // Finish stream stream.end(); await once(stream, 'finish'); + this.assertNotAborted(); // Make sure file is uploaded if (!existsSync(paths.tmp)) throw new Error('Temporary file is not found'); @@ -97,4 +96,9 @@ export class UploadSession { this.onCommit?.(); } } + + public assertNotAborted() { + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (this.error !== undefined) throw this.error; + } } From 12c877188acc165d6025d595521ea477d933309f Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 17:17:57 +0300 Subject: [PATCH 21/37] chore: normalize paths --- packages/app/src/electron/requests/storage/FilesStorage.ts | 7 ++++--- .../app/src/electron/requests/storage/PathsResolver.ts | 4 +++- packages/app/src/electron/utils/files.ts | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/app/src/electron/requests/storage/FilesStorage.ts b/packages/app/src/electron/requests/storage/FilesStorage.ts index a868b40d3..ae16646c7 100644 --- a/packages/app/src/electron/requests/storage/FilesStorage.ts +++ b/packages/app/src/electron/requests/storage/FilesStorage.ts @@ -1,4 +1,5 @@ import { existsSync, renameSync, rmSync, statSync } from 'fs'; +import path from 'path'; import recursive from 'recursive-readdir'; import { readFile, rm } from 'fs/promises'; @@ -61,11 +62,11 @@ export class FilesStorage { const files = await recursive(filesDir); return files .values() - .map((path) => + .filter((path) => this.paths.isAllowedPath(path)) + .map((filename) => // Remove root path - path.slice(filesDir.length), + filename.slice(filesDir.length).split(path.sep).join('/'), ) - .filter((path) => this.paths.isAllowedPath(path)) .toArray(); } } diff --git a/packages/app/src/electron/requests/storage/PathsResolver.ts b/packages/app/src/electron/requests/storage/PathsResolver.ts index 921884dce..7990b4085 100644 --- a/packages/app/src/electron/requests/storage/PathsResolver.ts +++ b/packages/app/src/electron/requests/storage/PathsResolver.ts @@ -56,7 +56,9 @@ export class PathsResolver { public isAllowedPath(filename: string) { const { tmpPrefix } = this.config; - return filename.split('/').every((segment) => !segment.startsWith(tmpPrefix)); + return filename + .split(path.sep) + .every((segment) => !segment.startsWith(tmpPrefix)); } public getPrefix() { diff --git a/packages/app/src/electron/utils/files.ts b/packages/app/src/electron/utils/files.ts index d0563c612..ae5c784be 100644 --- a/packages/app/src/electron/utils/files.ts +++ b/packages/app/src/electron/utils/files.ts @@ -3,10 +3,10 @@ import path from 'path'; import { isDevMode } from './app'; -const isRootedPath = (root: string, targetPath: string) => { - const pathSegments = targetPath.split(path.sep); +export const isRootedPath = (root: string, targetPath: string, separator = path.sep) => { + const pathSegments = targetPath.split(separator); return root - .split(path.sep) + .split(separator) .every((rootSegment, index) => rootSegment === pathSegments[index]); }; From 14910d76644862d484cb1ebf16ef006056243777 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 18:57:24 +0300 Subject: [PATCH 22/37] fix: use mutex --- .../requests/storage/UploadSessions.ts | 62 ++++++++++++------- .../requests/storage/storage-backend.test.ts | 22 +++++++ 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/packages/app/src/electron/requests/storage/UploadSessions.ts b/packages/app/src/electron/requests/storage/UploadSessions.ts index eac645ddd..4bcfd15fa 100644 --- a/packages/app/src/electron/requests/storage/UploadSessions.ts +++ b/packages/app/src/electron/requests/storage/UploadSessions.ts @@ -1,3 +1,5 @@ +import { Mutex } from 'async-mutex'; + import { PathsResolver } from './PathsResolver'; import { UploadSession } from './UploadSession'; @@ -8,40 +10,54 @@ export class UploadSessions { constructor(private readonly paths: PathsResolver) {} + private readonly pathMutexMap = new Map(); + private getPathMutex(resolvedPath: string) { + const mutex = this.pathMutexMap.get(resolvedPath); + if (mutex) return mutex; + + const newMutex = new Mutex(); + this.pathMutexMap.set(resolvedPath, newMutex); + + return newMutex; + } + public async create(fileId: string, subdir: string) { if (!this.paths.isAllowedPath(fileId)) throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); const paths = this.paths.getFilePaths(subdir, fileId); - const { resolvedPath } = paths; - - // Cancel previous session - const previousSessionId = this.pathUploadSessions.get(resolvedPath); - if (previousSessionId !== undefined) { - // TODO: schedule deletion by timeout for case the session will not be accessed - const session = this.uploadSessions.get(previousSessionId); - if (session) { - await session.abort(new Error('Another session is started')); + const mutex = this.getPathMutex(paths.resolvedPath); + + return mutex.runExclusive(async () => { + // Cancel previous session + const previousSessionId = this.pathUploadSessions.get(paths.resolvedPath); + if (previousSessionId !== undefined) { + // TODO: schedule deletion by timeout for case the session will not be accessed + const session = this.uploadSessions.get(previousSessionId); + if (session) { + await session.abort(new Error('Another session is started')); + } } - } - // Start new session - const sessionId = String(++this.uploadId); + // Start new session + const session = new UploadSession(paths); - const session = new UploadSession(paths); - await session.init(() => { - this.uploadSessions.delete(sessionId); + const sessionId = String(++this.uploadId); + this.pathUploadSessions.set(paths.resolvedPath, sessionId); + this.uploadSessions.set(sessionId, session); - // Terminate session - const currentSessionId = this.pathUploadSessions.get(paths.resolvedPath); - if (currentSessionId === sessionId) - this.pathUploadSessions.delete(paths.resolvedPath); - }); + await session.init(() => { + // Terminate session + this.uploadSessions.delete(sessionId); - this.pathUploadSessions.set(resolvedPath, sessionId); - this.uploadSessions.set(sessionId, session); + // Cleanup related + const currentSessionId = this.pathUploadSessions.get(paths.resolvedPath); + if (currentSessionId === sessionId) + this.pathUploadSessions.delete(paths.resolvedPath); + }); - return sessionId; + return sessionId; + }); } public getByFilename(filename: string) { diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 8992b44c0..1ef6c5065 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -143,6 +143,28 @@ describe('Upload sessions', () => { ).toHaveLength(1024); }); + test('No race conditions for sessions', async () => { + onTestFinished(enableStorage()); + + const sessionIds = await Promise.all([ + storageApi.createUploadSession('filename', '/foo/bar'), + storageApi.createUploadSession('filename', '/foo/bar'), + storageApi.createUploadSession('filename', '/foo/bar'), + storageApi.createUploadSession('filename', '/foo/bar'), + storageApi.createUploadSession('filename', '/foo/bar'), + ]); + + await expect( + Promise.allSettled( + sessionIds.map((id) => + storageApi.uploadChunk(id, getRandomBytes(100).buffer), + ), + ).then((results) => + results.filter((result) => result.status === 'fulfilled'), + ), + ).resolves.toHaveLength(1); + }); + test('Many sessions may exists in parallel', async () => { onTestFinished(enableStorage()); From 6919bf33080a0192783ea2754b24c5f039b6d118 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 18:57:46 +0300 Subject: [PATCH 23/37] chore: don't throw error for abort calls --- packages/app/src/electron/requests/storage/UploadSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/electron/requests/storage/UploadSession.ts b/packages/app/src/electron/requests/storage/UploadSession.ts index 9ca64f101..18b5e8f23 100644 --- a/packages/app/src/electron/requests/storage/UploadSession.ts +++ b/packages/app/src/electron/requests/storage/UploadSession.ts @@ -44,7 +44,7 @@ export class UploadSession { private error?: unknown; public async abort(error: unknown) { - if (this.error !== undefined) throw new Error('Already aborted'); + if (this.error !== undefined) return; // Set error immediately this.error = error; From 0462dc2e5c42108804411efacd595a43275b0cdb Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 18:59:52 +0300 Subject: [PATCH 24/37] chore: cleanup code --- .../requests/storage/UploadSessions.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/app/src/electron/requests/storage/UploadSessions.ts b/packages/app/src/electron/requests/storage/UploadSessions.ts index 4bcfd15fa..8b1aba967 100644 --- a/packages/app/src/electron/requests/storage/UploadSessions.ts +++ b/packages/app/src/electron/requests/storage/UploadSessions.ts @@ -4,23 +4,11 @@ import { PathsResolver } from './PathsResolver'; import { UploadSession } from './UploadSession'; export class UploadSessions { - private uploadId = 0; private readonly pathUploadSessions = new Map(); private readonly uploadSessions = new Map(); constructor(private readonly paths: PathsResolver) {} - private readonly pathMutexMap = new Map(); - private getPathMutex(resolvedPath: string) { - const mutex = this.pathMutexMap.get(resolvedPath); - if (mutex) return mutex; - - const newMutex = new Mutex(); - this.pathMutexMap.set(resolvedPath, newMutex); - - return newMutex; - } - public async create(fileId: string, subdir: string) { if (!this.paths.isAllowedPath(fileId)) throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); @@ -42,7 +30,7 @@ export class UploadSessions { // Start new session const session = new UploadSession(paths); - const sessionId = String(++this.uploadId); + const sessionId = this.getSessionId(); this.pathUploadSessions.set(paths.resolvedPath, sessionId); this.uploadSessions.set(sessionId, session); @@ -70,4 +58,20 @@ export class UploadSessions { public getById(sessionId: string) { return this.uploadSessions.get(sessionId) ?? null; } + + private uploadId = 0; + private getSessionId() { + return String(++this.uploadId); + } + + private readonly pathMutexMap = new Map(); + private getPathMutex(resolvedPath: string) { + const mutex = this.pathMutexMap.get(resolvedPath); + if (mutex) return mutex; + + const newMutex = new Mutex(); + this.pathMutexMap.set(resolvedPath, newMutex); + + return newMutex; + } } From c5adcb1f7c4765340c4a27f95552ddd0c34876b8 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 21:31:03 +0300 Subject: [PATCH 25/37] fix: handle stream errors --- .../requests/storage/UploadSession.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/app/src/electron/requests/storage/UploadSession.ts b/packages/app/src/electron/requests/storage/UploadSession.ts index 18b5e8f23..842d8ef83 100644 --- a/packages/app/src/electron/requests/storage/UploadSession.ts +++ b/packages/app/src/electron/requests/storage/UploadSession.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ import { once } from 'node:events'; import { createWriteStream, @@ -62,7 +63,30 @@ export class UploadSession { if (!this.stream) throw new Error('Session is not initialized yet'); if (!this.stream.write(new Uint8Array(buffer))) { - await once(this.stream, 'drain'); + const stream = this.stream; + await new Promise((resolve, reject) => { + const cleanup = () => { + stream.off('drain', onDrain); + stream.off('close', onClose); + stream.off('error', onError); + }; + const onDrain = () => { + cleanup(); + resolve(); + }; + const onClose = () => { + cleanup(); + reject(this.error ?? new Error('Upload stream closed before drain')); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + stream.once('drain', onDrain); + stream.once('close', onClose); + stream.once('error', onError); + }); } } From 803a81f56ec73f8eebf060558b35e4a246299ee6 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 21:45:55 +0300 Subject: [PATCH 26/37] fix: remove mutex by usage counters --- .../src/electron/requests/storage/MutexMap.ts | 41 +++++++++++++++++++ .../requests/storage/UploadSessions.ts | 18 ++------ 2 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/electron/requests/storage/MutexMap.ts diff --git a/packages/app/src/electron/requests/storage/MutexMap.ts b/packages/app/src/electron/requests/storage/MutexMap.ts new file mode 100644 index 000000000..9163f4389 --- /dev/null +++ b/packages/app/src/electron/requests/storage/MutexMap.ts @@ -0,0 +1,41 @@ +import { Mutex, MutexInterface } from 'async-mutex'; + +export class MutexMap { + private readonly mutexMap = new Map< + K, + { + mutex: Mutex; + users: number; + } + >(); + + private getMutexById(id: K) { + const state = this.mutexMap.get(id); + if (state) return state; + + const newState = { + mutex: new Mutex(), + users: 0, + }; + this.mutexMap.set(id, newState); + + return newState; + } + + public runExclusive(id: K, callback: MutexInterface.Worker): Promise { + const state = this.getMutexById(id); + + state.users++; + return state.mutex.runExclusive(callback).finally(() => { + state.users--; + if ( + state.users > 0 || + state.mutex.isLocked() || + this.mutexMap.get(id) !== state + ) + return; + + this.mutexMap.delete(id); + }); + } +} diff --git a/packages/app/src/electron/requests/storage/UploadSessions.ts b/packages/app/src/electron/requests/storage/UploadSessions.ts index 8b1aba967..502994d2a 100644 --- a/packages/app/src/electron/requests/storage/UploadSessions.ts +++ b/packages/app/src/electron/requests/storage/UploadSessions.ts @@ -1,9 +1,9 @@ -import { Mutex } from 'async-mutex'; - +import { MutexMap } from './MutexMap'; import { PathsResolver } from './PathsResolver'; import { UploadSession } from './UploadSession'; export class UploadSessions { + private readonly mutexMap = new MutexMap(); private readonly pathUploadSessions = new Map(); private readonly uploadSessions = new Map(); @@ -14,9 +14,8 @@ export class UploadSessions { throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); const paths = this.paths.getFilePaths(subdir, fileId); - const mutex = this.getPathMutex(paths.resolvedPath); - return mutex.runExclusive(async () => { + return this.mutexMap.runExclusive(paths.resolvedPath, async () => { // Cancel previous session const previousSessionId = this.pathUploadSessions.get(paths.resolvedPath); if (previousSessionId !== undefined) { @@ -63,15 +62,4 @@ export class UploadSessions { private getSessionId() { return String(++this.uploadId); } - - private readonly pathMutexMap = new Map(); - private getPathMutex(resolvedPath: string) { - const mutex = this.pathMutexMap.get(resolvedPath); - if (mutex) return mutex; - - const newMutex = new Mutex(); - this.pathMutexMap.set(resolvedPath, newMutex); - - return newMutex; - } } From b2f77f3580ad372570515f6e24f0975ffe3e8567 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 22:47:48 +0300 Subject: [PATCH 27/37] chore: retain cancelled session errors --- .../electron/requests/storage/Tombstones.ts | 61 +++++++++++++++++++ .../requests/storage/UploadSessions.ts | 23 ++++--- .../requests/storage/storage-backend.test.ts | 35 +++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/electron/requests/storage/Tombstones.ts diff --git a/packages/app/src/electron/requests/storage/Tombstones.ts b/packages/app/src/electron/requests/storage/Tombstones.ts new file mode 100644 index 000000000..daea45b22 --- /dev/null +++ b/packages/app/src/electron/requests/storage/Tombstones.ts @@ -0,0 +1,61 @@ +export class Tombstones { + private readonly entries = new Map< + K, + { value: V; expiresAt: number; timer: ReturnType } + >(); + + constructor(private readonly timeoutMs: number) { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) + throw new Error('Tombstone timeout must be a non-negative number'); + } + + public set(key: K, value: V) { + this.delete(key); + + const entry = { + value, + expiresAt: Date.now() + this.timeoutMs, + timer: undefined as unknown as ReturnType, + }; + + entry.timer = setTimeout(() => this.expire(key, entry), this.timeoutMs); + this.entries.set(key, entry); + } + + public get(key: K): V | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + + // Also enforce expiry if the timer callback has not run yet. + if (entry.expiresAt <= Date.now()) { + this.expire(key, entry); + return undefined; + } + + return entry.value; + } + + public delete(key: K) { + const entry = this.entries.get(key); + if (!entry) return false; + + clearTimeout(entry.timer); + return this.entries.delete(key); + } + + public clear() { + for (const entry of this.entries.values()) clearTimeout(entry.timer); + this.entries.clear(); + } + + private expire( + key: K, + expected: { value: V; expiresAt: number; timer: ReturnType }, + ) { + // Don't let an old timer remove a newer tombstone for the same key. + if (this.entries.get(key) !== expected) return; + + clearTimeout(expected.timer); + this.entries.delete(key); + } +} diff --git a/packages/app/src/electron/requests/storage/UploadSessions.ts b/packages/app/src/electron/requests/storage/UploadSessions.ts index 502994d2a..7552a0de0 100644 --- a/packages/app/src/electron/requests/storage/UploadSessions.ts +++ b/packages/app/src/electron/requests/storage/UploadSessions.ts @@ -1,11 +1,13 @@ import { MutexMap } from './MutexMap'; import { PathsResolver } from './PathsResolver'; +import { Tombstones } from './Tombstones'; import { UploadSession } from './UploadSession'; export class UploadSessions { - private readonly mutexMap = new MutexMap(); private readonly pathUploadSessions = new Map(); private readonly uploadSessions = new Map(); + private readonly mutexMap = new MutexMap(); + private readonly tombstones = new Tombstones(60_000); constructor(private readonly paths: PathsResolver) {} @@ -19,10 +21,12 @@ export class UploadSessions { // Cancel previous session const previousSessionId = this.pathUploadSessions.get(paths.resolvedPath); if (previousSessionId !== undefined) { - // TODO: schedule deletion by timeout for case the session will not be accessed const session = this.uploadSessions.get(previousSessionId); if (session) { - await session.abort(new Error('Another session is started')); + const error = new Error('Another session is started'); + this.tombstones.set(previousSessionId, error); + this.uploadSessions.delete(previousSessionId); + await session.abort(error); } } @@ -49,13 +53,18 @@ export class UploadSessions { public getByFilename(filename: string) { const sessionId = this.pathUploadSessions.get(filename); - - if (sessionId === undefined) return null; - return this.uploadSessions.get(sessionId) ?? null; + return sessionId ? this.getById(sessionId) : undefined; } public getById(sessionId: string) { - return this.uploadSessions.get(sessionId) ?? null; + const session = this.uploadSessions.get(sessionId); + if (session) return session; + + const tombstone = this.tombstones.get(sessionId); + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (tombstone) throw tombstone; + + return null; } private uploadId = 0; diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 1ef6c5065..0e352971d 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -143,6 +143,41 @@ describe('Upload sessions', () => { ).toHaveLength(1024); }); + test('Aborted session reasons are retained temporarily, then discarded', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + onTestFinished(enableStorage()); + + const previousSession = await storageApi.createUploadSession( + 'filename', + '/foo/bar', + ); + await storageApi.uploadChunk(previousSession, getRandomBytes(1024).buffer); + + const currentSession = await storageApi.createUploadSession( + 'filename', + '/foo/bar', + ); + await storageApi.commitUpload(currentSession); + + await expect( + storageApi.uploadChunk(previousSession, getRandomBytes(1024).buffer), + ).rejects.toThrow('Another session is started'); + + await vi.advanceTimersByTimeAsync(59_000); + await expect( + storageApi.uploadChunk(previousSession, getRandomBytes(1024).buffer), + ).rejects.toThrow('Another session is started'); + + await vi.advanceTimersByTimeAsync(10_000); + + await expect( + storageApi.uploadChunk(previousSession, getRandomBytes(1024).buffer), + ).rejects.toThrow('No session found'); + }); + test('No race conditions for sessions', async () => { onTestFinished(enableStorage()); From 2f0181b5c677ab17116aad2bebb5d4ca78bcf7cb Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 23:13:07 +0300 Subject: [PATCH 28/37] fix: check resolved paths --- .../src/electron/requests/storage/FilesStorage.ts | 12 ++++++------ .../src/electron/requests/storage/UploadSessions.ts | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/app/src/electron/requests/storage/FilesStorage.ts b/packages/app/src/electron/requests/storage/FilesStorage.ts index ae16646c7..6b1524471 100644 --- a/packages/app/src/electron/requests/storage/FilesStorage.ts +++ b/packages/app/src/electron/requests/storage/FilesStorage.ts @@ -13,11 +13,11 @@ export class FilesStorage { ) {} async get(fileId: string, subdir: string) { - // Do not return special files - if (!this.paths.isAllowedPath(fileId)) return null; - const { resolvedPath, bkp } = this.paths.getFilePaths(subdir, fileId); + // Do not return special files + if (!this.paths.isAllowedPath(resolvedPath)) return null; + // Restore file if (!existsSync(resolvedPath) && existsSync(bkp) && statSync(bkp).isFile()) { renameSync(bkp, resolvedPath); @@ -31,11 +31,11 @@ export class FilesStorage { async delete(fileIds: string[], subdir: string) { for (const fileId of fileIds) { - // Skip special files - if (!this.paths.isAllowedPath(fileId)) continue; - const { resolvedPath, bkp, tmp } = this.paths.getFilePaths(subdir, fileId); + // Skip special files + if (!this.paths.isAllowedPath(resolvedPath)) continue; + if (existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) { // Recursive directory deletion await rm(resolvedPath, { force: true, recursive: true }); diff --git a/packages/app/src/electron/requests/storage/UploadSessions.ts b/packages/app/src/electron/requests/storage/UploadSessions.ts index 7552a0de0..46eca0a10 100644 --- a/packages/app/src/electron/requests/storage/UploadSessions.ts +++ b/packages/app/src/electron/requests/storage/UploadSessions.ts @@ -12,11 +12,11 @@ export class UploadSessions { constructor(private readonly paths: PathsResolver) {} public async create(fileId: string, subdir: string) { - if (!this.paths.isAllowedPath(fileId)) - throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); - const paths = this.paths.getFilePaths(subdir, fileId); + if (!this.paths.isAllowedPath(paths.resolvedPath)) + throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); + return this.mutexMap.runExclusive(paths.resolvedPath, async () => { // Cancel previous session const previousSessionId = this.pathUploadSessions.get(paths.resolvedPath); From bfa825796db7f8b4c6ba32a4679949fa8cbea55b Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Fri, 2 Oct 2026 23:26:24 +0300 Subject: [PATCH 29/37] chore: set deadline --- packages/app/src/core/database/ManagedDatabase.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/core/database/ManagedDatabase.ts b/packages/app/src/core/database/ManagedDatabase.ts index f53d1c013..bb5813aea 100644 --- a/packages/app/src/core/database/ManagedDatabase.ts +++ b/packages/app/src/core/database/ManagedDatabase.ts @@ -44,9 +44,9 @@ export type Options = { }; }; -const requestRunOnIdle = (cb: () => void) => { +const requestRunOnIdle = (cb: () => void, deadline: number) => { if (typeof globalThis.requestIdleCallback === 'function') { - globalThis.requestIdleCallback(cb); + globalThis.requestIdleCallback(cb, { timeout: deadline }); return; } @@ -72,7 +72,7 @@ export class ManagedDatabase implements IManagedDatabase { // Auto sync changes this.debouncedSync = debounce( () => { - requestRunOnIdle(() => this.sync()); + requestRunOnIdle(() => this.sync(), sync.deadline); }, { wait: sync.delay, From e90aa866808d61955dd52696f868b059da2e6d33 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sat, 3 Oct 2026 18:11:39 +0300 Subject: [PATCH 30/37] feat: support file streaming --- ...lectronFilesController.integration.test.ts | 2 +- .../requests/storage/FileReadSession.ts | 61 +++++++++++++++++++ .../requests/storage/FileReadSessions.ts | 56 +++++++++++++++++ .../src/electron/requests/storage/index.ts | 9 +++ .../app/src/electron/requests/storage/main.ts | 41 +++++++++++++ .../src/electron/requests/storage/renderer.ts | 41 +++++++++++-- 6 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/electron/requests/storage/FileReadSession.ts create mode 100644 packages/app/src/electron/requests/storage/FileReadSessions.ts diff --git a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts index 7f1919370..45aa574ab 100644 --- a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts +++ b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts @@ -270,7 +270,7 @@ describe('FS backend', () => { 'No handler registered for storage.list', ); await expect(filesController.get('/')).rejects.toThrow( - 'No handler registered for storage.get', + 'No handler registered for storage.createReadSession', ); await expect(filesController.write('/', new ArrayBuffer(1))).rejects.toThrow( 'No handler registered for storage.createUploadSession', diff --git a/packages/app/src/electron/requests/storage/FileReadSession.ts b/packages/app/src/electron/requests/storage/FileReadSession.ts new file mode 100644 index 000000000..01ce9594d --- /dev/null +++ b/packages/app/src/electron/requests/storage/FileReadSession.ts @@ -0,0 +1,61 @@ +import { FileHandle, open } from 'node:fs/promises'; + +export class FileReadSession { + private closed = false; + private file: { + fd: FileHandle; + offset: number; + } | null = null; + public async open(filename: string) { + const fd = await open(filename, 'r'); + + this.file = { fd, offset: 0 }; + this.closed = false; + } + + public async size() { + const state = this.getState(); + const { size } = await state.fd.stat(); + return size; + } + + public async read(size: number): Promise { + const state = this.getState(); + + const buffer = new ArrayBuffer(size); + const { bytesRead } = await state.fd.read( + new Uint8Array(buffer), + 0, + size, + state.offset, + ); + + state.offset += bytesRead; + + // No data + if (bytesRead === 0) { + // await this.close(); + return null; + } + + return buffer.slice(0, bytesRead); + } + + public async close(): Promise { + if (this.closed) return; + + this.closed = true; + if (this.file) { + const state = this.file; + this.file = null; + await state.fd.close(); + } + } + + public getState() { + if (this.closed) throw new Error('File descriptor is closed'); + if (!this.file) throw new Error('File is not opened'); + + return this.file; + } +} diff --git a/packages/app/src/electron/requests/storage/FileReadSessions.ts b/packages/app/src/electron/requests/storage/FileReadSessions.ts new file mode 100644 index 000000000..22ea22300 --- /dev/null +++ b/packages/app/src/electron/requests/storage/FileReadSessions.ts @@ -0,0 +1,56 @@ +import { existsSync, renameSync, statSync } from 'fs'; + +import { FileReadSession } from './FileReadSession'; +import { PathsResolver } from './PathsResolver'; + +export class FileReadSessions { + private readonly sessions = new Map(); + + constructor(private readonly paths: PathsResolver) {} + + public async create(fileId: string, subdir: string) { + const paths = this.paths.getFilePaths(subdir, fileId); + + if (!this.paths.isAllowedPath(paths.resolvedPath)) + throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); + + // Restore file + if ( + !existsSync(paths.resolvedPath) && + existsSync(paths.bkp) && + statSync(paths.bkp).isFile() + ) { + renameSync(paths.bkp, paths.resolvedPath); + } + + // Exist for non exists files + if (!existsSync(paths.resolvedPath)) return null; + + const session = new FileReadSession(); + await session.open(paths.resolvedPath); + + const sessionId = this.getSessionId(); + this.sessions.set(sessionId, session); + + return sessionId; + } + + public getById(sessionId: string): FileReadSession | null { + return this.sessions.get(sessionId) ?? null; + } + + public async close(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + + if (!session) return; + + this.sessions.delete(sessionId); + + await session.close(); + } + + private sessionId = 0; + private getSessionId(): string { + return String(++this.sessionId); + } +} diff --git a/packages/app/src/electron/requests/storage/index.ts b/packages/app/src/electron/requests/storage/index.ts index 8a4811f81..3cb78776b 100644 --- a/packages/app/src/electron/requests/storage/index.ts +++ b/packages/app/src/electron/requests/storage/index.ts @@ -1,10 +1,19 @@ import { createChannel } from '../../utils/ipc'; export type StorageChannelAPI = { + // File write createUploadSession: (fileId: string, subdir: string) => Promise; uploadChunk: (sessionId: string, buffer: ArrayBuffer) => Promise; commitUpload: (sessionId: string) => Promise; + // File reads + createReadSession: ( + fileId: string, + subdir: string, + ) => Promise<{ id: string; size: number } | null>; + readChunk: (sessionId: string, size: number) => Promise; + closeReader: (sessionId: string) => Promise; + get: (id: string, subdir: string) => Promise; delete: (ids: string[], subdir: string) => Promise; list: (subdir: string) => Promise; diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index fcc8bc619..44ac2ab3f 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -3,6 +3,7 @@ import { ApiToHandlers } from '@electron/utils/ipc'; import { ipcMainHandler } from '../../utils/ipc/ipcMainHandler'; +import { FileReadSessions } from './FileReadSessions'; import { FilesStorage } from './FilesStorage'; import { PathsResolver, StorageOptions } from './PathsResolver'; import { UploadSessions } from './UploadSessions'; @@ -13,6 +14,8 @@ export const createStorageBackend = ({ tmpPrefix = '.tmp-fs' }: StorageOptions = const uploadSessions = new UploadSessions(pathsResolver); const storage = new FilesStorage(pathsResolver, uploadSessions); + const readSessions = new FileReadSessions(pathsResolver); + return { async get({ req: [fileId, subdir] }) { return storage.get(fileId, subdir); @@ -58,6 +61,44 @@ export const createStorageBackend = ({ tmpPrefix = '.tmp-fs' }: StorageOptions = await session.commit(); }, + + createReadSession: async function ({ + req: [fileId, subdir], + }: { + req: [fileId: string, subdir: string]; + ctx: Electron.IpcMainInvokeEvent; + }) { + const sessionId = await readSessions.create(fileId, subdir); + if (sessionId === null) return null; + + const session = readSessions.getById(sessionId); + if (!session) throw new Error(`No session found for id ${sessionId}`); + + const size = await session.size(); + + return { id: sessionId, size }; + }, + readChunk: async function ({ + req: [sessionId, size], + }: { + req: [sessionId: string, size: number]; + ctx: Electron.IpcMainInvokeEvent; + }) { + const session = readSessions.getById(sessionId); + if (!session) throw new Error(`No session found for id ${sessionId}`); + + return session.read(size); + }, + closeReader: async function ({ + req: [sessionId], + }: { + req: [sessionId: string]; + ctx: Electron.IpcMainInvokeEvent; + }) { + const session = readSessions.getById(sessionId); + if (!session) throw new Error(`No session found for id ${sessionId}`); + await session.close(); + }, } satisfies ApiToHandlers; }; diff --git a/packages/app/src/electron/requests/storage/renderer.ts b/packages/app/src/electron/requests/storage/renderer.ts index 6a61c10bd..97bf82926 100644 --- a/packages/app/src/electron/requests/storage/renderer.ts +++ b/packages/app/src/electron/requests/storage/renderer.ts @@ -44,13 +44,42 @@ export class ElectronFilesController implements IFilesStorage { } public async get(id: string) { - return this.storageApi.get(id, this.subdirectory).then((buffer) => { - // Don't handle empty data - if (!buffer) return buffer; + const sessionInfo = await this.storageApi.createReadSession( + id, + this.subdirectory, + ); + if (!sessionInfo) return null; + + const { size, id: sessionId } = sessionInfo; + + const buffer = new ArrayBuffer(size, { maxByteLength: size * 3 }); + const bv = new Uint8Array(buffer); + + const chunkSize = 1024 ** 2 * (this.config.chunkSize ?? 5); + let offset = 0; + while (true) { + const chunk = await this.storageApi.readChunk(sessionId, chunkSize); + + // End when whole file is drained + if (!chunk) { + await this.storageApi.closeReader(sessionId); + if (buffer.byteLength !== offset) buffer.resize(offset); + break; + } + + // Extend buffer if needed + const leftSpace = buffer.byteLength - offset; + const extraSizeNeeded = chunk.byteLength - leftSpace; + if (extraSizeNeeded > 0) { + buffer.resize(buffer.byteLength + extraSizeNeeded); + } + + // Write + bv.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } - if (!this.encryption) return buffer; - return this.encryption.decrypt(buffer); - }); + return buffer; } public async delete(ids: string[]) { From 701e723aa07f9da2716ddd4c6d07204733e5baae Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Sat, 3 Oct 2026 18:45:41 +0300 Subject: [PATCH 31/37] chore: remove buffer resizing The final buffer must be not resizable, because TextEncoder does not work on mutable buffer. --- .../app/src/electron/requests/storage/renderer.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/app/src/electron/requests/storage/renderer.ts b/packages/app/src/electron/requests/storage/renderer.ts index 97bf82926..2bad9734e 100644 --- a/packages/app/src/electron/requests/storage/renderer.ts +++ b/packages/app/src/electron/requests/storage/renderer.ts @@ -52,8 +52,7 @@ export class ElectronFilesController implements IFilesStorage { const { size, id: sessionId } = sessionInfo; - const buffer = new ArrayBuffer(size, { maxByteLength: size * 3 }); - const bv = new Uint8Array(buffer); + const buffer = new Uint8Array(size); const chunkSize = 1024 ** 2 * (this.config.chunkSize ?? 5); let offset = 0; @@ -63,23 +62,15 @@ export class ElectronFilesController implements IFilesStorage { // End when whole file is drained if (!chunk) { await this.storageApi.closeReader(sessionId); - if (buffer.byteLength !== offset) buffer.resize(offset); break; } - // Extend buffer if needed - const leftSpace = buffer.byteLength - offset; - const extraSizeNeeded = chunk.byteLength - leftSpace; - if (extraSizeNeeded > 0) { - buffer.resize(buffer.byteLength + extraSizeNeeded); - } - // Write - bv.set(new Uint8Array(chunk), offset); + buffer.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; } - return buffer; + return buffer.buffer; } public async delete(ids: string[]) { From 70f7a51cd14015be57247bb91fc1ad849d94e730 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Mon, 5 Oct 2026 19:48:42 +0300 Subject: [PATCH 32/37] fix: always close reader session --- ...lectronFilesController.integration.test.ts | 44 ++++++++++++++++++- .../src/electron/requests/storage/renderer.ts | 28 ++++++------ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts index 45aa574ab..5fadadebb 100644 --- a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts +++ b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts @@ -1,8 +1,10 @@ import { vol } from 'memfs'; import { getRandomBytes } from '@core/encryption/utils/random'; +import { ipcMainHandler } from '@electron/utils/ipc/ipcMainHandler'; -import { enableStorage } from './main'; +import { createStorageBackend, enableStorage } from './main'; import { ElectronFilesController, storageApi } from './renderer'; +import { storageChannel } from '.'; vi.mock('fs', () => vi.importActual('@mocks/fs')); vi.mock('fs/promises', () => vi.importActual('@mocks/fs/promises')); @@ -385,3 +387,43 @@ describe('Deletion tests', () => { await expect(files.list()).resolves.toHaveLength(0); }); }); + +describe('Resources must not leak', () => { + beforeAll(() => { + vi.clearAllMocks(); + }); + afterAll(() => { + vi.clearAllMocks(); + }); + beforeEach(() => { + vol.reset(); + }); + + test('Reader must close session for successful reads', async () => { + const backend = createStorageBackend(); + onTestFinished(storageChannel.server(ipcMainHandler, backend)); + + const files = new ElectronFilesController(storageApi, '/'); + await createFiles(files, ['/data']); + + const closeReaderSpy = vi.spyOn(backend, 'closeReader'); + await expect(files.get('/data')).resolves.not.toThrow(); + expect(closeReaderSpy).toHaveBeenCalledTimes(1); + }); + + test('Reader must close session when error occurs', async () => { + const backend = createStorageBackend(); + onTestFinished(storageChannel.server(ipcMainHandler, backend)); + + const files = new ElectronFilesController(storageApi, '/'); + await createFiles(files, ['/data']); + + const closeReaderSpy = vi.spyOn(backend, 'closeReader'); + + vi.spyOn(backend, 'readChunk').mockRejectedValueOnce( + new Error('Emulated read error'), + ); + await expect(files.get('/data')).rejects.toThrow('Emulated read error'); + expect(closeReaderSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/electron/requests/storage/renderer.ts b/packages/app/src/electron/requests/storage/renderer.ts index 2bad9734e..2f62369be 100644 --- a/packages/app/src/electron/requests/storage/renderer.ts +++ b/packages/app/src/electron/requests/storage/renderer.ts @@ -53,21 +53,21 @@ export class ElectronFilesController implements IFilesStorage { const { size, id: sessionId } = sessionInfo; const buffer = new Uint8Array(size); - - const chunkSize = 1024 ** 2 * (this.config.chunkSize ?? 5); - let offset = 0; - while (true) { - const chunk = await this.storageApi.readChunk(sessionId, chunkSize); - - // End when whole file is drained - if (!chunk) { - await this.storageApi.closeReader(sessionId); - break; + try { + const chunkSize = 1024 ** 2 * (this.config.chunkSize ?? 5); + let offset = 0; + while (true) { + const chunk = await this.storageApi.readChunk(sessionId, chunkSize); + + // End when whole file is drained + if (!chunk) break; + + // Write + buffer.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; } - - // Write - buffer.set(new Uint8Array(chunk), offset); - offset += chunk.byteLength; + } finally { + await this.storageApi.closeReader(sessionId); } return buffer.buffer; From b1931621030abf643a386d1970f3c9e820777e65 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 6 Oct 2026 00:39:08 +0300 Subject: [PATCH 33/37] chore: remove unused encryption interface --- .../src/electron/requests/storage/renderer.ts | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/app/src/electron/requests/storage/renderer.ts b/packages/app/src/electron/requests/storage/renderer.ts index 2f62369be..bdcea310f 100644 --- a/packages/app/src/electron/requests/storage/renderer.ts +++ b/packages/app/src/electron/requests/storage/renderer.ts @@ -1,4 +1,3 @@ -import { IEncryptionController } from '@core/encryption'; import { BufferCursor } from '@core/encryption/utils/bytes/BufferCursor'; import { IFilesStorage } from '@core/features/files'; @@ -13,24 +12,16 @@ export class ElectronFilesController implements IFilesStorage { constructor( private readonly storageApi: StorageChannelAPI, private readonly subdirectory: string, - private readonly encryption?: IEncryptionController, private readonly config: { chunkSize?: number } = {}, - ) { - this.subdirectory = subdirectory; - this.encryption = encryption; - } + ) {} public async write(filename: string, buffer: ArrayBuffer) { - const encryptedBuffer = this.encryption - ? await this.encryption.encrypt(buffer) - : buffer; - const sessionId = await this.storageApi.createUploadSession( filename, this.subdirectory, ); - const bufferCursor = new BufferCursor(encryptedBuffer); + const bufferCursor = new BufferCursor(buffer); while (bufferCursor.getRemainingBytes() > 0) { const slice = bufferCursor.readBytes( 1024 ** 2 * (this.config.chunkSize ?? 5), @@ -43,9 +34,9 @@ export class ElectronFilesController implements IFilesStorage { await this.storageApi.commitUpload(sessionId); } - public async get(id: string) { + public async get(filename: string) { const sessionInfo = await this.storageApi.createReadSession( - id, + filename, this.subdirectory, ); if (!sessionInfo) return null; @@ -73,8 +64,8 @@ export class ElectronFilesController implements IFilesStorage { return buffer.buffer; } - public async delete(ids: string[]) { - return this.storageApi.delete(ids, this.subdirectory); + public async delete(filenames: string[]) { + return this.storageApi.delete(filenames, this.subdirectory); } public async list() { From f0d018f1c0eb52a3f7dd993e895cb805534f19c0 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 6 Oct 2026 00:48:31 +0300 Subject: [PATCH 34/37] fix: return nothing for directory fetching --- .../ElectronFilesController.integration.test.ts | 16 ++++++++++------ .../requests/storage/FileReadSessions.ts | 3 ++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts index 5fadadebb..b84643363 100644 --- a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts +++ b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts @@ -41,12 +41,9 @@ describe('Chunking', () => { ])( 'File reading returns exact equal buffer as original data (chunk size $chunkSize Mb, file size $size Mb)', async ({ size, chunkSize }) => { - const filesController = new ElectronFilesController( - storageApi, - 'size-test', - undefined, - { chunkSize }, - ); + const filesController = new ElectronFilesController(storageApi, 'size-test', { + chunkSize, + }); const originalBuffer = getRandomBytes(1024 ** 2 * size); await filesController.write('data', originalBuffer.buffer); @@ -109,6 +106,13 @@ describe('Basic ops', () => { ), ).resolves.toMatchSnapshot('Files content'); }); + + test('Fetching for directory returns nothing', async () => { + const files = new ElectronFilesController(storageApi, 'vaultDir'); + await createFiles(files, ['/foo/bar/baz/test.txt']); + + await expect(files.get('/foo')).resolves.toBe(null); + }); }); describe('Files paths', () => { diff --git a/packages/app/src/electron/requests/storage/FileReadSessions.ts b/packages/app/src/electron/requests/storage/FileReadSessions.ts index 22ea22300..d7f241566 100644 --- a/packages/app/src/electron/requests/storage/FileReadSessions.ts +++ b/packages/app/src/electron/requests/storage/FileReadSessions.ts @@ -24,7 +24,8 @@ export class FileReadSessions { } // Exist for non exists files - if (!existsSync(paths.resolvedPath)) return null; + if (!existsSync(paths.resolvedPath) || !statSync(paths.resolvedPath).isFile()) + return null; const session = new FileReadSession(); await session.open(paths.resolvedPath); From 565a6fca26b0d89de9ed62eea9f00fcc8330f1f0 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 6 Oct 2026 00:57:04 +0300 Subject: [PATCH 35/37] fix: proxy only string keys --- packages/app/src/electron/utils/ipc/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/src/electron/utils/ipc/index.ts b/packages/app/src/electron/utils/ipc/index.ts index 95750fa3e..1a0803f40 100644 --- a/packages/app/src/electron/utils/ipc/index.ts +++ b/packages/app/src/electron/utils/ipc/index.ts @@ -39,7 +39,9 @@ export const createChannel = (options: ChannelOptions) => { ): T => { // Return proxy object with virtual callbacks return new Proxy({} as any, { - get: function (_target, methodName: string) { + get: function (target, methodName: string) { + if (typeof methodName !== 'string') return target[methodName]; + const endpoint = getResolvedEndpointName(methodName); const mapper = mappers[methodName as keyof T]; return (...args: Parameters) => { @@ -59,8 +61,10 @@ export const createChannel = (options: ChannelOptions) => { callbacks: ApiToHandlers, ): (() => void) => { // Subscribe on channels - const cleanups = Object.entries(callbacks).map(([methodName, callback]) => - receiver(getResolvedEndpointName(methodName), callback), + const cleanups = Object.keys(callbacks).map((methodName) => + receiver(getResolvedEndpointName(methodName), (params) => + callbacks[methodName](params), + ), ); return () => { cleanups.forEach((cleanup) => cleanup()); From a8b9281aeacae15d60d7171338ac00bca9df5886 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 6 Oct 2026 17:21:35 +0300 Subject: [PATCH 36/37] chore: remove unused method --- .../requests/storage/FileReadSessions.ts | 3 +-- .../electron/requests/storage/FilesStorage.ts | 21 ++----------------- .../src/electron/requests/storage/index.ts | 1 - .../app/src/electron/requests/storage/main.ts | 4 ---- .../requests/storage/storage-backend.test.ts | 12 ++++++++--- 5 files changed, 12 insertions(+), 29 deletions(-) diff --git a/packages/app/src/electron/requests/storage/FileReadSessions.ts b/packages/app/src/electron/requests/storage/FileReadSessions.ts index d7f241566..4aa5a56f7 100644 --- a/packages/app/src/electron/requests/storage/FileReadSessions.ts +++ b/packages/app/src/electron/requests/storage/FileReadSessions.ts @@ -11,8 +11,7 @@ export class FileReadSessions { public async create(fileId: string, subdir: string) { const paths = this.paths.getFilePaths(subdir, fileId); - if (!this.paths.isAllowedPath(paths.resolvedPath)) - throw new Error(`File name must not start from '${this.paths.getPrefix()}'`); + if (!this.paths.isAllowedPath(paths.resolvedPath)) return null; // Restore file if ( diff --git a/packages/app/src/electron/requests/storage/FilesStorage.ts b/packages/app/src/electron/requests/storage/FilesStorage.ts index 6b1524471..aec8cd0e2 100644 --- a/packages/app/src/electron/requests/storage/FilesStorage.ts +++ b/packages/app/src/electron/requests/storage/FilesStorage.ts @@ -1,8 +1,8 @@ -import { existsSync, renameSync, rmSync, statSync } from 'fs'; +import { existsSync, rmSync, statSync } from 'fs'; import path from 'path'; import recursive from 'recursive-readdir'; -import { readFile, rm } from 'fs/promises'; +import { rm } from 'fs/promises'; import { PathsResolver } from './PathsResolver'; import { UploadSessions } from './UploadSessions'; @@ -12,23 +12,6 @@ export class FilesStorage { private readonly opSessions: UploadSessions, ) {} - async get(fileId: string, subdir: string) { - const { resolvedPath, bkp } = this.paths.getFilePaths(subdir, fileId); - - // Do not return special files - if (!this.paths.isAllowedPath(resolvedPath)) return null; - - // Restore file - if (!existsSync(resolvedPath) && existsSync(bkp) && statSync(bkp).isFile()) { - renameSync(bkp, resolvedPath); - } - - if (!existsSync(resolvedPath) || !statSync(resolvedPath).isFile()) return null; - - const buffer = await readFile(resolvedPath); - return new Uint8Array(buffer).buffer; - } - async delete(fileIds: string[], subdir: string) { for (const fileId of fileIds) { const { resolvedPath, bkp, tmp } = this.paths.getFilePaths(subdir, fileId); diff --git a/packages/app/src/electron/requests/storage/index.ts b/packages/app/src/electron/requests/storage/index.ts index 3cb78776b..8e1c36d15 100644 --- a/packages/app/src/electron/requests/storage/index.ts +++ b/packages/app/src/electron/requests/storage/index.ts @@ -14,7 +14,6 @@ export type StorageChannelAPI = { readChunk: (sessionId: string, size: number) => Promise; closeReader: (sessionId: string) => Promise; - get: (id: string, subdir: string) => Promise; delete: (ids: string[], subdir: string) => Promise; list: (subdir: string) => Promise; }; diff --git a/packages/app/src/electron/requests/storage/main.ts b/packages/app/src/electron/requests/storage/main.ts index 44ac2ab3f..2eeadd5b6 100644 --- a/packages/app/src/electron/requests/storage/main.ts +++ b/packages/app/src/electron/requests/storage/main.ts @@ -17,10 +17,6 @@ export const createStorageBackend = ({ tmpPrefix = '.tmp-fs' }: StorageOptions = const readSessions = new FileReadSessions(pathsResolver); return { - async get({ req: [fileId, subdir] }) { - return storage.get(fileId, subdir); - }, - async delete({ req: [fileIds, subdir] }) { return storage.delete(fileIds, subdir); }, diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index 0e352971d..e77c7760b 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -43,7 +43,9 @@ describe('Special names', () => { ); await expect(storageApi.list('/')).resolves.toEqual([]); - await expect(storageApi.get('.tmp-tmp-filename', '/foo/bar')).resolves.toBe(null); + await expect( + storageApi.createReadSession('.tmp-tmp-filename', '/foo/bar'), + ).resolves.toBe(null); // Deletion does not work await expect( @@ -241,7 +243,9 @@ describe('File integrity', () => { fileContent, ); - await expect(storageApi.get('filename', '/foo/bar')).resolves.toStrictEqual( + const session = await storageApi.createReadSession('filename', '/foo/bar'); + expect(session).not.toBe(null); + await expect(storageApi.readChunk(session!.id, 10_000)).resolves.toStrictEqual( fileContent.buffer, ); }); @@ -281,6 +285,8 @@ describe('File integrity', () => { await storageApi.delete(['note'], '/foo/bar'); expect(vol.existsSync(backupPath)).toBe(false); - await expect(storageApi.get('note', '/foo/bar')).resolves.toBe(null); + await expect(storageApi.createReadSession('note', '/foo/bar')).resolves.toBe( + null, + ); }); }); From cb7277c7a0c72e7286bf32b6d38f5467659e5804 Mon Sep 17 00:00:00 2001 From: Robert Vitonsky Date: Tue, 6 Oct 2026 23:29:02 +0300 Subject: [PATCH 37/37] fix: update files listing for windows --- packages/app/src/__tests__/utils/fs.ts | 7 ++++ ...lectronFilesController.integration.test.ts | 10 ++--- .../electron/requests/storage/FilesStorage.ts | 37 +++++++++++++------ ...onFilesController.integration.test.ts.snap | 12 +++--- .../requests/storage/storage-backend.test.ts | 14 +++---- packages/app/src/electron/utils/files.ts | 3 +- 6 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/__tests__/utils/fs.ts diff --git a/packages/app/src/__tests__/utils/fs.ts b/packages/app/src/__tests__/utils/fs.ts new file mode 100644 index 000000000..3469f489e --- /dev/null +++ b/packages/app/src/__tests__/utils/fs.ts @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/no-base-to-string */ +import { vol } from 'memfs'; + +export const readdir = (path: string) => + vol + .readdirSync(path, { recursive: true }) + .sort((a, b) => String(a).localeCompare(String(b))); diff --git a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts index b84643363..0b4b01f04 100644 --- a/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts +++ b/packages/app/src/electron/requests/storage/ElectronFilesController.integration.test.ts @@ -1,14 +1,16 @@ import { vol } from 'memfs'; import { getRandomBytes } from '@core/encryption/utils/random'; import { ipcMainHandler } from '@electron/utils/ipc/ipcMainHandler'; +import { readdir } from '@tests/utils/fs'; import { createStorageBackend, enableStorage } from './main'; import { ElectronFilesController, storageApi } from './renderer'; import { storageChannel } from '.'; vi.mock('fs', () => vi.importActual('@mocks/fs')); +vi.mock('node:fs', () => vi.importActual('@mocks/fs')); vi.mock('fs/promises', () => vi.importActual('@mocks/fs/promises')); -vi.mock('recursive-readdir', () => vi.importActual('@mocks/recursive-readdir')); +vi.mock('node:fs/promises', () => vi.importActual('@mocks/fs/promises')); vi.mock('electron', () => vi.importActual('@mocks/electron')); const getBufferFromText = (text: string) => new Uint8Array(Buffer.from(text)).buffer; @@ -71,7 +73,7 @@ describe('Basic ops', () => { test('File uploading persist data on disk', async () => { // No files in FS - expect(vol.readdirSync('/', { recursive: true })).toEqual([]); + expect(readdir('/')).toEqual([]); const filesController = new ElectronFilesController(storageApi, 'vaultDir'); @@ -85,9 +87,7 @@ describe('Basic ops', () => { 'Controller files list after writing', ); - expect(vol.readdirSync('/', { recursive: true }), 'Explore FS').toMatchSnapshot( - 'FS structure after writing', - ); + expect(readdir('/'), 'Explore FS').toMatchSnapshot('FS structure after writing'); // Explore files content await expect( diff --git a/packages/app/src/electron/requests/storage/FilesStorage.ts b/packages/app/src/electron/requests/storage/FilesStorage.ts index aec8cd0e2..a20014433 100644 --- a/packages/app/src/electron/requests/storage/FilesStorage.ts +++ b/packages/app/src/electron/requests/storage/FilesStorage.ts @@ -1,8 +1,7 @@ import { existsSync, rmSync, statSync } from 'fs'; import path from 'path'; -import recursive from 'recursive-readdir'; -import { rm } from 'fs/promises'; +import { readdir, rm } from 'fs/promises'; import { PathsResolver } from './PathsResolver'; import { UploadSessions } from './UploadSessions'; @@ -42,14 +41,30 @@ export class FilesStorage { if (!existsSync(filesDir)) return []; - const files = await recursive(filesDir); - return files - .values() - .filter((path) => this.paths.isAllowedPath(path)) - .map((filename) => - // Remove root path - filename.slice(filesDir.length).split(path.sep).join('/'), - ) - .toArray(); + const files: string[] = []; + for (const entry of await readdir(filesDir, { + recursive: true, + withFileTypes: true, + })) { + // Skip non-files + if (!entry.isFile() || entry.isSymbolicLink()) continue; + + const resolvedFilename = path.resolve( + path.join(entry.parentPath, entry.name), + ); + + // Skip special files + if (!this.paths.isAllowedPath(resolvedFilename)) continue; + + // Remove root path + const virtualPath = resolvedFilename + .slice(filesDir.length) + .split(path.sep) + .join('/'); + + files.push(virtualPath); + } + + return files; } } diff --git a/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap b/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap index 8d91b3361..0c0089433 100644 --- a/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap +++ b/packages/app/src/electron/requests/storage/__snapshots__/ElectronFilesController.integration.test.ts.snap @@ -9,16 +9,16 @@ exports[`Basic ops > File uploading persist data on disk > Controller files list exports[`Basic ops > File uploading persist data on disk > FS structure after writing 1`] = ` [ - "/home/userData/appDir/app", + "/home", + "/home/userData", "/home/userData/appDir", + "/home/userData/appDir/app", + "/home/userData/appDir/app/vaultDir", + "/home/userData/appDir/app/vaultDir/foo", "/home/userData/appDir/app/vaultDir/foo/bar", "/home/userData/appDir/app/vaultDir/foo/bar/baz", - "/home/userData/appDir/app/vaultDir/foo", - "/home", - "/home/userData/appDir/app/vaultDir/test.txt", "/home/userData/appDir/app/vaultDir/foo/bar/baz/test.txt", - "/home/userData", - "/home/userData/appDir/app/vaultDir", + "/home/userData/appDir/app/vaultDir/test.txt", ] `; diff --git a/packages/app/src/electron/requests/storage/storage-backend.test.ts b/packages/app/src/electron/requests/storage/storage-backend.test.ts index e77c7760b..cda301b27 100644 --- a/packages/app/src/electron/requests/storage/storage-backend.test.ts +++ b/packages/app/src/electron/requests/storage/storage-backend.test.ts @@ -1,12 +1,12 @@ import { vol } from 'memfs'; import { getRandomBytes } from '@core/encryption/utils/random'; +import { readdir } from '@tests/utils/fs'; import { enableStorage } from './main'; import { storageApi } from './renderer'; vi.mock('fs', () => vi.importActual('@mocks/fs')); vi.mock('fs/promises', () => vi.importActual('@mocks/fs/promises')); -vi.mock('recursive-readdir', () => vi.importActual('@mocks/recursive-readdir')); vi.mock('electron', () => vi.importActual('@mocks/electron')); describe('Special names', () => { @@ -38,7 +38,7 @@ describe('Special names', () => { await storageApi.uploadChunk(session, getRandomBytes(1024).buffer); // Temp file is present in FS - expect(vol.readdirSync('/', { recursive: true })).toContain( + expect(readdir('/')).toContain( '/home/userData/appDir/app/foo/bar/.tmp-tmp-filename', ); @@ -51,7 +51,7 @@ describe('Special names', () => { await expect( storageApi.delete(['.tmp-tmp-filename'], '/foo/bar'), ).resolves.toBeUndefined(); - expect(vol.readdirSync('/', { recursive: true })).toContain( + expect(readdir('/')).toContain( '/home/userData/appDir/app/foo/bar/.tmp-tmp-filename', ); }); @@ -262,14 +262,10 @@ describe('File integrity', () => { fileContent, ); vol.writeFileSync('/home/userData/appDir/app/foo/bar/filename', fileContent); - expect(vol.readdirSync('/', { recursive: true })).toContainEqual( - expect.stringContaining('filename'), - ); + expect(readdir('/')).toContainEqual(expect.stringContaining('filename')); await storageApi.delete(['filename'], '/foo/bar'); - expect(vol.readdirSync('/', { recursive: true })).not.toContainEqual( - expect.stringContaining('filename'), - ); + expect(readdir('/')).not.toContainEqual(expect.stringContaining('filename')); }); test('Deleting a file removes its backup even when the target is missing', async () => { diff --git a/packages/app/src/electron/utils/files.ts b/packages/app/src/electron/utils/files.ts index ae5c784be..e179e5046 100644 --- a/packages/app/src/electron/utils/files.ts +++ b/packages/app/src/electron/utils/files.ts @@ -16,8 +16,9 @@ export const isRootedPath = (root: string, targetPath: string, separator = path. * In case resolved path are out of root path - thrown error */ export const joinPath = (root: string, ...segments: string[]) => { + const resolvedRoot = path.resolve(root); const resolvedPath = path.resolve(path.join(root, ...segments)); - if (!isRootedPath(root, resolvedPath)) { + if (!isRootedPath(resolvedRoot, resolvedPath)) { throw new TypeError('Resolved path is out of root directory'); }