diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index 2fd1ca680e2..df91e29a2ed 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -39,6 +39,7 @@ vi.mock('electron', () => ({ vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer })) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined), + scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), writeRemoteFileToClipboard: vi.fn() })) diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index b1da7e9e226..e12f7d633db 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -12,6 +12,8 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, + fsAccessMock, + fsLstatMock, fsMkdirMock, fsOpendirMock, fsRmMock, @@ -45,6 +47,8 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), + fsAccessMock: vi.fn(), + fsLstatMock: vi.fn(), fsMkdirMock: vi.fn(), fsOpendirMock: vi.fn(), fsRmMock: vi.fn(), @@ -68,11 +72,14 @@ vi.mock('node:child_process', () => ({ })) vi.mock('node:fs/promises', () => ({ + access: fsAccessMock, + lstat: fsLstatMock, mkdir: fsMkdirMock, opendir: fsOpendirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, + writeFile: fsWriteFileMock, default: { writeFile: fsWriteFileMock } @@ -134,6 +141,11 @@ import { setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' +const REMOTE_CLIPBOARD_STAGING_ROOT = join( + '/tmp', + `orca-clipboard-files${typeof process.getuid === 'function' ? `-${process.getuid()}` : ''}` +) + function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() for (const [channel, handler] of handleMock.mock.calls as [ @@ -187,6 +199,13 @@ describe('registerClipboardHandlers', () => { childStdinEndMock.mockClear() resolveAuthorizedPathMock.mockReset() resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + fsLstatMock.mockReset() + fsLstatMock.mockResolvedValue({ + isDirectory: () => true, + isSymbolicLink: () => false, + mode: 0o700, + uid: typeof process.getuid === 'function' ? process.getuid() : 0 + }) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) fsOpendirMock.mockReset() @@ -316,8 +335,8 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() const tempDir = join( - '/tmp', - 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + REMOTE_CLIPBOARD_STAGING_ROOT, + '1760000000000-00000000-0000-4000-8000-000000000000' ) const tempPath = join(tempDir, 'report.pdf') @@ -329,6 +348,10 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: true }) expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf') + expect(fsMkdirMock).toHaveBeenCalledWith(REMOTE_CLIPBOARD_STAGING_ROOT, { + recursive: true, + mode: 0o700 + }) expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 }) expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) expect(fsStatMock).toHaveBeenCalledWith(tempPath) @@ -353,7 +376,6 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: false, reason: 'is-directory' }) expect(provider.downloadFile).not.toHaveBeenCalled() - expect(fsMkdirMock).not.toHaveBeenCalled() expect(clipboardWriteBufferMock).not.toHaveBeenCalled() }) @@ -367,8 +389,8 @@ describe('registerClipboardHandlers', () => { const handlers = getRegisteredHandlers() const tempDir = join( - '/tmp', - 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + REMOTE_CLIPBOARD_STAGING_ROOT, + '1760000000000-00000000-0000-4000-8000-000000000000' ) const tempPath = join(tempDir, 'report.pdf') @@ -380,7 +402,12 @@ describe('registerClipboardHandlers', () => { ).rejects.toThrow('transfer failed') expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) - expect(fsRmMock).toHaveBeenCalledWith(tempDir, { recursive: true, force: true }) + expect(fsRmMock).toHaveBeenCalledWith(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }) expect(clipboardWriteBufferMock).not.toHaveBeenCalled() }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 275c0e26240..450c6a6e140 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -31,6 +31,7 @@ import { } from './clipboard-file-copy' import { cleanupExpiredRemoteClipboardFiles, + scheduleLegacyRemoteClipboardFileCleanup, writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' @@ -85,6 +86,7 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:saveImageAsTempFile') void cleanupExpiredRemoteClipboardFiles() + scheduleLegacyRemoteClipboardFileCleanup() ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { assertTrustedClipboardTextSender(event) diff --git a/src/main/window/clipboard-remote-file-cleanup.bench.test.ts b/src/main/window/clipboard-remote-file-cleanup.bench.test.ts new file mode 100644 index 00000000000..ea187ca9c40 --- /dev/null +++ b/src/main/window/clipboard-remote-file-cleanup.bench.test.ts @@ -0,0 +1,123 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { access, mkdir, mkdtemp, opendir, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { + cleanupExpiredRemoteClipboardStaging, + createRemoteClipboardTransferDirectory +} from './clipboard-remote-file-staging' + +const BENCH_ENABLED = process.env.ORCA_CLIPBOARD_CLEANUP_BENCH === '1' +const FOREIGN_ENTRY_COUNT = Number(process.env.ORCA_CLIPBOARD_CLEANUP_BENCH_ENTRIES ?? 100_000) +const WARMUP_RUNS = 3 +const SAMPLE_RUNS = 25 +const FIXTURE_PREFIX = 'orca-clipboard-cleanup-bench-' +const NOW_MS = 1_760_000_000_000 + +describe.skipIf(!BENCH_ENABLED)('remote clipboard cleanup benchmark', () => { + let fixtureRoot = '' + let freshTransfer = '' + + beforeAll(async () => { + fixtureRoot = await mkdtemp(join(tmpdir(), FIXTURE_PREFIX)) + await createForeignEntries(fixtureRoot, FOREIGN_ENTRY_COUNT) + const expiredTransfer = await createRemoteClipboardTransferDirectory( + fixtureRoot, + NOW_MS - 2 * 60 * 60 * 1000, + '00000000-0000-4000-8000-000000000000' + ) + freshTransfer = await createRemoteClipboardTransferDirectory( + fixtureRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000001' + ) + const expiredAt = new Date(NOW_MS - 2 * 60 * 60 * 1000) + await utimes(expiredTransfer, expiredAt, expiredAt) + await cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + + await expect(access(expiredTransfer)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(freshTransfer)).resolves.toBeUndefined() + expect(await countForeignEntries(fixtureRoot)).toBe(FOREIGN_ENTRY_COUNT) + }, 900_000) + + afterAll(async () => { + if ( + fixtureRoot && + dirname(fixtureRoot) === tmpdir() && + basename(fixtureRoot).startsWith(FIXTURE_PREFIX) + ) { + await rm(fixtureRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }) + } + }, 900_000) + + it('reports shared-root and owned-parent median and p95', async () => { + for (let index = 0; index < WARMUP_RUNS; index += 1) { + await scanSharedTempRoot(fixtureRoot) + await cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS) + } + + const sharedRootSamples: number[] = [] + const ownedParentSamples: number[] = [] + for (let index = 0; index < SAMPLE_RUNS; index += 1) { + sharedRootSamples.push(await measure(() => scanSharedTempRoot(fixtureRoot))) + ownedParentSamples.push( + await measure(() => cleanupExpiredRemoteClipboardStaging(fixtureRoot, NOW_MS)) + ) + } + + expect(await countForeignEntries(fixtureRoot)).toBe(FOREIGN_ENTRY_COUNT) + await expect(access(freshTransfer)).resolves.toBeUndefined() + const sharedRoot = summarize(sharedRootSamples) + const ownedParent = summarize(ownedParentSamples) + console.log('correctness=pass') + console.log(`post12917-shared-root median=${sharedRoot.median}ms p95=${sharedRoot.p95}ms`) + console.log(`owned-parent median=${ownedParent.median}ms p95=${ownedParent.p95}ms`) + }, 900_000) +}) + +async function createForeignEntries(root: string, count: number): Promise { + const batchSize = 128 + for (let start = 0; start < count; start += batchSize) { + const tasks: Promise[] = [] + for (let index = start; index < Math.min(start + batchSize, count); index += 1) { + const target = join(root, `foreign-${String(index).padStart(8, '0')}`) + tasks.push(index % 2 === 0 ? writeFile(target, '') : mkdir(target)) + } + await Promise.all(tasks) + } +} + +async function countForeignEntries(root: string): Promise { + return (await readdir(root)).filter((name) => name.startsWith('foreign-')).length +} + +async function scanSharedTempRoot(root: string): Promise { + const directory = await opendir(root) + try { + for await (const entry of directory) { + if (entry.isDirectory() && entry.name.startsWith('orca-clipboard-file-')) { + // Reproduces the remaining shared-root iteration after #12917. + } + } + } finally { + await directory.close().catch(() => undefined) + } +} + +async function measure(operation: () => Promise): Promise { + const startedAt = process.hrtime.bigint() + await operation() + return Number(process.hrtime.bigint() - startedAt) / 1_000_000 +} + +function summarize(samples: number[]): { median: string; p95: string } { + const sorted = [...samples].sort((left, right) => left - right) + const median = sorted[Math.ceil(sorted.length * 0.5) - 1] + const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1] + return { median: median.toFixed(3), p95: p95.toFixed(3) } +} diff --git a/src/main/window/clipboard-remote-file-cleanup.test.ts b/src/main/window/clipboard-remote-file-cleanup.test.ts deleted file mode 100644 index 56700cc025e..00000000000 --- a/src/main/window/clipboard-remote-file-cleanup.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' - -const { opendirMock, rmMock, statMock } = vi.hoisted(() => ({ - opendirMock: vi.fn(), - rmMock: vi.fn(), - statMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - mkdir: vi.fn(), - opendir: opendirMock, - rm: rmMock, - stat: statMock -})) -vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: vi.fn() -})) -vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) - -import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' - -const TTL_MS = 60 * 60 * 1000 -const NOW_MS = 1_760_000_000_000 - -function mockTempRoot(entries: Iterable<{ name: string; isDirectory: () => boolean }>): void { - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield* entries - }, - close: vi.fn().mockResolvedValue(undefined) - }) -} - -function* orcaStagingDirs(count: number): Generator<{ name: string; isDirectory: () => boolean }> { - for (let index = 0; index < count; index += 1) { - yield { name: `orca-clipboard-file-expired-${index}`, isDirectory: () => true } - } -} - -function* unrelatedTempEntries( - count: number -): Generator<{ name: string; isDirectory: () => boolean }> { - for (let index = 0; index < count; index += 1) { - // Mirrors a real temp root: mostly foreign entries, both files and directories. - yield { name: `unrelated-${index}`, isDirectory: () => index % 2 === 0 } - } -} - -describe('cleanupExpiredRemoteClipboardFiles', () => { - beforeEach(() => { - vi.clearAllMocks() - rmMock.mockResolvedValue(undefined) - statMock.mockResolvedValue({ mtimeMs: NOW_MS - TTL_MS - 1 }) - }) - - it('streams all entries with at most eight cleanups in flight', async () => { - mockTempRoot(orcaStagingDirs(257)) - let active = 0 - let peak = 0 - statMock.mockImplementation(async () => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => setImmediate(resolve)) - active -= 1 - return { mtimeMs: NOW_MS - TTL_MS - 1 } - }) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(rmMock).toHaveBeenCalledTimes(257) - expect(peak).toBe(8) - }) - - it('does no per-entry work for foreign temp-root entries', async () => { - mockTempRoot(unrelatedTempEntries(200_000)) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(statMock).not.toHaveBeenCalled() - expect(rmMock).not.toHaveBeenCalled() - }) - - it('acts on owned directories while the temp root is still being enumerated', async () => { - // Regression: the sweep materialized the whole temp root and built one promise - // per entry before doing any work, so a %TEMP% with ~1.2M unrelated entries - // froze the main process at startup (#12835). Streaming means an owned - // directory is swept before enumeration reaches the end. - let sweptDuringEnumeration = false - function* enumeration(): Generator<{ name: string; isDirectory: () => boolean }> { - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - for (const entry of unrelatedTempEntries(1_000)) { - sweptDuringEnumeration ||= rmMock.mock.calls.length > 0 - yield entry - } - } - mockTempRoot(enumeration()) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(sweptDuringEnumeration).toBe(true) - expect(rmMock).toHaveBeenCalledTimes(1) - }) - - it('removes only expired staging directories it owns', async () => { - function* interleaved(): Generator<{ name: string; isDirectory: () => boolean }> { - yield* unrelatedTempEntries(50_000) - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - yield* unrelatedTempEntries(50_000) - yield { name: 'orca-clipboard-file-fresh', isDirectory: () => true } - yield { name: 'orca-clipboard-file-plain-file', isDirectory: () => false } - } - mockTempRoot(interleaved()) - statMock.mockImplementation(async (targetPath: string) => ({ - mtimeMs: targetPath.endsWith('expired') ? NOW_MS - TTL_MS - 1 : NOW_MS - 1000 - })) - - await cleanupExpiredRemoteClipboardFiles(NOW_MS) - - expect(statMock).toHaveBeenCalledTimes(2) - expect(rmMock).toHaveBeenCalledTimes(1) - expect(rmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { - recursive: true, - force: true - }) - }) - - it('closes the temp-root handle and still sweeps when enumeration fails midway', async () => { - const close = vi.fn().mockResolvedValue(undefined) - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } - throw new Error('EIO') - }, - close - }) - - await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() - - expect(rmMock).toHaveBeenCalledTimes(1) - expect(close).toHaveBeenCalledTimes(1) - }) - - it('returns quietly when the temp root cannot be opened', async () => { - opendirMock.mockRejectedValue(new Error('EACCES')) - - await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() - - expect(statMock).not.toHaveBeenCalled() - expect(rmMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 2afc60cda30..965d894e06f 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { Dir } from 'node:fs' -import { mkdir, opendir, rm, stat } from 'node:fs/promises' +import { stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' @@ -12,14 +11,21 @@ import { type ClipboardFileDeps, type ClipboardFileResult } from './clipboard-file-copy' +import { + cleanupExpiredRemoteClipboardStaging, + cleanupLegacyRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + removeRemoteClipboardTransferDirectory +} from './clipboard-remote-file-staging' type RemoteClipboardFileDeps = Omit const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 -const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' -const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 +const REMOTE_CLIPBOARD_CLEANUP_RETRY_MS = 60 * 1000 +const REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS = 30 * 1000 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) +let legacyCleanupScheduled = false export async function writeRemoteFileToClipboard({ remotePath, @@ -39,11 +45,8 @@ export async function writeRemoteFileToClipboard({ throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') } - const tempDir = join( - app.getPath('temp'), - `${REMOTE_CLIPBOARD_FILE_PREFIX}${Date.now()}-${randomUUID()}` - ) - await mkdir(tempDir, { mode: 0o700 }) + const tempRoot = app.getPath('temp') + const tempDir = await createRemoteClipboardTransferDirectory(tempRoot, Date.now(), randomUUID()) const localPath = join( tempDir, sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath)) @@ -70,62 +73,33 @@ export async function writeRemoteFileToClipboard({ // Why: OS file clipboards keep a path reference, so the staged copy must // survive after this IPC call long enough for the user to paste it. keepTempFile = true - scheduleRemoteClipboardFileCleanup(tempDir) + scheduleRemoteClipboardFileCleanup(tempRoot, tempDir) } return result } finally { if (!keepTempFile) { - await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + await removeRemoteClipboardTransferDirectory(tempRoot, tempDir) } } } -// Why: the OS temp root is shared with every other program and routinely holds -// millions of unrelated entries on long-lived machines, so this startup sweep -// streams it and only ever retains work for entries it actually owns. export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { - const tempRoot = app.getPath('temp') - let tempRootDir: Dir - try { - tempRootDir = await opendir(tempRoot) - } catch { + await cleanupExpiredRemoteClipboardStaging(app.getPath('temp'), nowMs) +} + +export async function cleanupLegacyRemoteClipboardFiles(nowMs = Date.now()): Promise { + await cleanupLegacyRemoteClipboardStaging(app.getPath('temp'), nowMs) +} + +export function scheduleLegacyRemoteClipboardFileCleanup(): void { + if (legacyCleanupScheduled) { return } - - const pending = new Set>() - try { - for await (const entry of tempRootDir) { - if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - continue - } - const cleanup = cleanupExpiredRemoteClipboardDirectory(join(tempRoot, entry.name), nowMs) - pending.add(cleanup) - void cleanup.finally(() => pending.delete(cleanup)) - if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { - await Promise.race(pending) - } - } - } catch { - // Why: a partial best-effort sweep beats failing startup on a temp-root read error. - } finally { - // Why: exhausting the iterator already closes the handle; closing again is harmless. - await tempRootDir.close().catch(() => undefined) - } - await Promise.all(pending) -} - -async function cleanupExpiredRemoteClipboardDirectory( - tempDir: string, - nowMs: number -): Promise { - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { - await rm(tempDir, { recursive: true, force: true }) - } - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. - } + legacyCleanupScheduled = true + const timer = setTimeout(() => { + void cleanupLegacyRemoteClipboardFiles() + }, REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS) + unrefTimer(timer) } function sanitizeLocalClipboardFilename(remoteBasename: string): string { @@ -140,10 +114,22 @@ function sanitizeLocalClipboardFilename(remoteBasename: string): string { return sanitized } -function scheduleRemoteClipboardFileCleanup(tempDir: string): void { +function scheduleRemoteClipboardFileCleanup( + tempRoot: string, + tempDir: string, + delayMs = REMOTE_CLIPBOARD_FILE_TTL_MS +): void { const timer = setTimeout(() => { - void rm(tempDir, { recursive: true, force: true }).catch(() => undefined) - }, REMOTE_CLIPBOARD_FILE_TTL_MS) + void removeRemoteClipboardTransferDirectory(tempRoot, tempDir).then((removed) => { + if (!removed) { + scheduleRemoteClipboardFileCleanup(tempRoot, tempDir, REMOTE_CLIPBOARD_CLEANUP_RETRY_MS) + } + }) + }, delayMs) + unrefTimer(timer) +} + +function unrefTimer(timer: ReturnType): void { if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } diff --git a/src/main/window/clipboard-remote-file-staging.test.ts b/src/main/window/clipboard-remote-file-staging.test.ts new file mode 100644 index 00000000000..d9b42fa6dce --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging.test.ts @@ -0,0 +1,337 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join, resolve } from 'node:path' + +const { accessMock, lstatMock, mkdirMock, opendirMock, rmMock, writeFileMock } = vi.hoisted(() => ({ + accessMock: vi.fn(), + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + opendirMock: vi.fn(), + rmMock: vi.fn(), + writeFileMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + access: accessMock, + lstat: lstatMock, + mkdir: mkdirMock, + opendir: opendirMock, + rm: rmMock, + writeFile: writeFileMock +})) + +import { + cleanupExpiredRemoteClipboardStaging, + cleanupLegacyRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + getRemoteClipboardStagingRoot, + removeRemoteClipboardTransferDirectory +} from './clipboard-remote-file-staging' + +const TTL_MS = 60 * 60 * 1000 +const NOW_MS = 1_760_000_000_000 +const TEMP_ROOT = resolve('fixture-temp') +const UID_SUFFIX = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' +const STAGING_ROOT = join(TEMP_ROOT, `orca-clipboard-files${UID_SUFFIX}`) +const MARKER_PATH = join(STAGING_ROOT, '.legacy-cleanup-complete') +const UUID_A = '00000000-0000-4000-8000-000000000000' +const UUID_B = '00000000-0000-4000-8000-000000000001' +const EXPIRED_TRANSFER = `1759990000000-${UUID_A}` +const FRESH_TRANSFER = `1760000000000-${UUID_B}` +const LEGACY_EXPIRED = `orca-clipboard-file-${EXPIRED_TRANSFER}` +const LEGACY_FRESH = `orca-clipboard-file-${FRESH_TRANSFER}` + +type MockDirent = { name: string; isDirectory: () => boolean } + +function directoryEntry(name: string, isDirectory = true): MockDirent { + return { name, isDirectory: () => isDirectory } +} + +function openedDirectory(entries: Iterable): { + [Symbol.asyncIterator]: () => AsyncGenerator + close: ReturnType +} { + return { + async *[Symbol.asyncIterator]() { + yield* entries + }, + close: vi.fn().mockResolvedValue(undefined) + } +} + +function safeDirectoryStats(mtimeMs = NOW_MS - TTL_MS - 1) { + return { + isDirectory: () => true, + isSymbolicLink: () => false, + mode: 0o700, + mtimeMs, + uid: typeof process.getuid === 'function' ? process.getuid() : 0 + } +} + +function missingError(): NodeJS.ErrnoException { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +describe('remote clipboard staging ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + accessMock.mockRejectedValue(missingError()) + lstatMock.mockResolvedValue(safeDirectoryStats()) + mkdirMock.mockResolvedValue(undefined) + opendirMock.mockResolvedValue(openedDirectory([])) + rmMock.mockResolvedValue(undefined) + writeFileMock.mockResolvedValue(undefined) + }) + + it('creates a private owned parent and a per-transfer child', async () => { + const result = await createRemoteClipboardTransferDirectory(TEMP_ROOT, NOW_MS, UUID_A) + + expect(result).toBe(join(STAGING_ROOT, `${NOW_MS}-${UUID_A}`)) + expect(mkdirMock).toHaveBeenNthCalledWith(1, STAGING_ROOT, { + recursive: true, + mode: 0o700 + }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, result, { mode: 0o700 }) + }) + + it('scans only the owned parent during normal crash cleanup', async () => { + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(opendirMock).toHaveBeenCalledOnce() + expect(opendirMock).toHaveBeenCalledWith(STAGING_ROOT) + expect(opendirMock).not.toHaveBeenCalledWith(TEMP_ROOT) + }) + + it('removes expired crash leftovers and preserves fresh or unknown children', async () => { + opendirMock.mockResolvedValue( + openedDirectory([ + directoryEntry(EXPIRED_TRANSFER), + directoryEntry(FRESH_TRANSFER), + directoryEntry('unknown-child'), + directoryEntry('.legacy-cleanup-complete', false) + ]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + safeDirectoryStats(targetPath.endsWith(FRESH_TRANSFER) ? NOW_MS - 1_000 : undefined) + ) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledOnce() + expect(rmMock).toHaveBeenCalledWith(join(STAGING_ROOT, EXPIRED_TRANSFER), { + force: true, + maxRetries: 3, + recursive: true, + retryDelay: 100 + }) + }) + + it('rejects a symlinked owned parent before opening it', async () => { + lstatMock.mockResolvedValue({ + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + }) + + await expect(cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS)).resolves.toBeUndefined() + + expect(opendirMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) + + it('rejects wrong-owner and insecure POSIX parents', async () => { + if (typeof process.getuid !== 'function') { + return + } + lstatMock + .mockResolvedValueOnce({ ...safeDirectoryStats(), uid: process.getuid() + 1 }) + .mockResolvedValueOnce({ ...safeDirectoryStats(), mode: 0o755 }) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(opendirMock).not.toHaveBeenCalled() + }) + + it('rejects child symlinks and names outside the owned parent', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(EXPIRED_TRANSFER), directoryEntry('..')]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + targetPath === STAGING_ROOT + ? safeDirectoryStats() + : { + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + } + ) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(lstatMock).not.toHaveBeenCalledWith(TEMP_ROOT) + expect(rmMock).not.toHaveBeenCalled() + }) + + it('contains explicit removals to direct owned children', async () => { + expect( + await removeRemoteClipboardTransferDirectory(TEMP_ROOT, join(STAGING_ROOT, '..', 'victim')) + ).toBe(false) + expect(await removeRemoteClipboardTransferDirectory(TEMP_ROOT, STAGING_ROOT)).toBe(false) + + expect(rmMock).not.toHaveBeenCalled() + }) + + it('treats an already-removed transfer as cleanup success', async () => { + lstatMock.mockImplementation(async (targetPath: string) => { + if (targetPath === STAGING_ROOT) { + return safeDirectoryStats() + } + throw missingError() + }) + + await expect( + removeRemoteClipboardTransferDirectory(TEMP_ROOT, join(STAGING_ROOT, EXPIRED_TRANSFER)) + ).resolves.toBe(true) + + expect(rmMock).not.toHaveBeenCalled() + }) + + it('continues past Windows-style lock failures and retries on a later sweep', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(EXPIRED_TRANSFER), directoryEntry(`1759990000000-${UUID_B}`)]) + ) + rmMock.mockImplementation(async (targetPath: string) => { + if (targetPath.endsWith(EXPIRED_TRANSFER)) { + throw Object.assign(new Error('locked'), { code: 'EPERM' }) + } + }) + + await expect(cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS)).resolves.toBeUndefined() + + expect(rmMock).toHaveBeenCalledTimes(2) + rmMock.mockResolvedValue(undefined) + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + expect(rmMock).toHaveBeenCalledTimes(4) + }) + + it('keeps owned-child cleanup concurrency at eight', async () => { + const entries = Array.from({ length: 257 }, (_, index) => + directoryEntry(`1759990000000-00000000-0000-4000-8000-${String(index).padStart(12, '0')}`) + ) + opendirMock.mockResolvedValue(openedDirectory(entries)) + let active = 0 + let peak = 0 + lstatMock.mockImplementation(async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolveImmediate) => setImmediate(resolveImmediate)) + active -= 1 + return safeDirectoryStats() + }) + + await cleanupExpiredRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledTimes(257) + expect(peak).toBe(8) + }) +}) + +describe('legacy remote clipboard staging compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + accessMock.mockRejectedValue(missingError()) + lstatMock.mockResolvedValue(safeDirectoryStats()) + mkdirMock.mockResolvedValue(undefined) + opendirMock.mockResolvedValue(openedDirectory([])) + rmMock.mockResolvedValue(undefined) + writeFileMock.mockResolvedValue(undefined) + }) + + it('caps a large foreign-root scan at 4096 total entries', async () => { + let visited = 0 + function* foreignEntries(): Generator { + for (let index = 0; index < 200_000; index += 1) { + visited += 1 + yield directoryEntry(`foreign-${index}`, index % 2 === 0) + } + } + opendirMock.mockResolvedValue(openedDirectory(foreignEntries())) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(visited).toBe(4_096) + expect(lstatMock).toHaveBeenCalledOnce() + expect(rmMock).not.toHaveBeenCalled() + expect(writeFileMock).toHaveBeenCalledWith(MARKER_PATH, '', { + flag: 'wx', + mode: 0o600 + }) + }) + + it('removes only exact expired legacy children encountered inside the bound', async () => { + opendirMock.mockResolvedValue( + openedDirectory([ + directoryEntry('orca-clipboard-file-lookalike'), + directoryEntry(LEGACY_EXPIRED), + directoryEntry(LEGACY_EXPIRED, false) + ]) + ) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).toHaveBeenCalledOnce() + expect(rmMock).toHaveBeenCalledWith(join(TEMP_ROOT, LEGACY_EXPIRED), { + force: true, + maxRetries: 3, + recursive: true, + retryDelay: 100 + }) + expect(writeFileMock).toHaveBeenCalledOnce() + }) + + it('does not mark migration complete while a fresh or locked legacy child remains', async () => { + opendirMock.mockResolvedValue( + openedDirectory([directoryEntry(LEGACY_FRESH), directoryEntry(LEGACY_EXPIRED)]) + ) + lstatMock.mockImplementation(async (targetPath: string) => + safeDirectoryStats(targetPath.endsWith(LEGACY_FRESH) ? NOW_MS - 1_000 : undefined) + ) + rmMock.mockRejectedValue(Object.assign(new Error('locked'), { code: 'EBUSY' })) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(writeFileMock).not.toHaveBeenCalled() + }) + + it('never follows legacy symlinks outside the shared temp root', async () => { + opendirMock.mockResolvedValue(openedDirectory([directoryEntry(LEGACY_EXPIRED)])) + lstatMock.mockImplementation(async (targetPath: string) => + targetPath === STAGING_ROOT + ? safeDirectoryStats() + : { + ...safeDirectoryStats(), + isDirectory: () => false, + isSymbolicLink: () => true + } + ) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(rmMock).not.toHaveBeenCalled() + expect(writeFileMock).toHaveBeenCalledOnce() + }) + + it('skips the shared root after the one-time marker exists', async () => { + accessMock.mockResolvedValue(undefined) + + await cleanupLegacyRemoteClipboardStaging(TEMP_ROOT, NOW_MS) + + expect(accessMock).toHaveBeenCalledWith(MARKER_PATH) + expect(opendirMock).not.toHaveBeenCalled() + }) + + it('uses a platform-safe owned parent name', () => { + expect(getRemoteClipboardStagingRoot(TEMP_ROOT)).toBe(STAGING_ROOT) + }) +}) diff --git a/src/main/window/clipboard-remote-file-staging.ts b/src/main/window/clipboard-remote-file-staging.ts new file mode 100644 index 00000000000..911dc94077d --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging.ts @@ -0,0 +1,222 @@ +import type { Dir, Stats } from 'node:fs' +import { access, lstat, mkdir, opendir, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' + +const REMOTE_CLIPBOARD_STAGING_ROOT_NAME = 'orca-clipboard-files' +const REMOTE_CLIPBOARD_LEGACY_PREFIX = 'orca-clipboard-file-' +const REMOTE_CLIPBOARD_MIGRATION_MARKER = '.legacy-cleanup-complete' +const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 +const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 +// Why: compatibility cleanup must never restore O(shared temp root) work. +const REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT = 4_096 +const UUID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const TRANSFER_DIRECTORY_PATTERN = new RegExp(`^\\d{1,16}-${UUID_PATTERN}$`, 'i') +const LEGACY_DIRECTORY_PATTERN = new RegExp( + `^${REMOTE_CLIPBOARD_LEGACY_PREFIX}\\d{1,16}-${UUID_PATTERN}$`, + 'i' +) +const REMOVE_OPTIONS = { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 +} as const + +type CleanupResult = 'failed' | 'fresh' | 'ignored' | 'removed' + +export function getRemoteClipboardStagingRoot(tempRoot: string): string { + const uidSuffix = typeof process.getuid === 'function' ? `-${process.getuid()}` : '' + return join(tempRoot, `${REMOTE_CLIPBOARD_STAGING_ROOT_NAME}${uidSuffix}`) +} + +export async function createRemoteClipboardTransferDirectory( + tempRoot: string, + createdAtMs: number, + transferId: string +): Promise { + const stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + const transferDirectory = join(stagingRoot, `${createdAtMs}-${transferId}`) + if ( + !isTransferDirectoryName(basename(transferDirectory)) || + !isDirectChild(stagingRoot, transferDirectory) + ) { + throw new Error('Remote clipboard transfer path escapes its staging root') + } + await mkdir(transferDirectory, { mode: 0o700 }) + const transferStats = await lstat(transferDirectory) + if (!isSafeOwnedDirectory(transferStats)) { + throw new Error('Remote clipboard transfer directory is unsafe') + } + return transferDirectory +} + +export async function cleanupExpiredRemoteClipboardStaging( + tempRoot: string, + nowMs = Date.now() +): Promise { + let stagingRoot: string + try { + stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + } catch { + return + } + await sweepDirectories(stagingRoot, nowMs, isTransferDirectoryName) +} + +export async function cleanupLegacyRemoteClipboardStaging( + tempRoot: string, + nowMs = Date.now() +): Promise { + let stagingRoot: string + try { + stagingRoot = await ensureRemoteClipboardStagingRoot(tempRoot) + } catch { + return + } + + const markerPath = join(stagingRoot, REMOTE_CLIPBOARD_MIGRATION_MARKER) + try { + await access(markerPath) + return + } catch (error) { + if (!isMissingPathError(error)) { + return + } + } + + const result = await sweepDirectories( + tempRoot, + nowMs, + isLegacyTransferDirectoryName, + REMOTE_CLIPBOARD_LEGACY_ENTRY_LIMIT + ) + if (result.complete && !result.hasFreshDirectories && !result.hasFailures) { + await writeFile(markerPath, '', { flag: 'wx', mode: 0o600 }).catch(() => undefined) + } +} + +export async function removeRemoteClipboardTransferDirectory( + tempRoot: string, + transferDirectory: string +): Promise { + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + if ( + !isDirectChild(stagingRoot, transferDirectory) || + !isTransferDirectoryName(basename(resolve(transferDirectory))) + ) { + return false + } + try { + await ensureRemoteClipboardStagingRoot(tempRoot) + const transferStats = await lstat(transferDirectory) + if (!isSafeOwnedDirectory(transferStats)) { + return false + } + await rm(transferDirectory, REMOVE_OPTIONS) + return true + } catch (error) { + return isMissingPathError(error) + } +} + +async function ensureRemoteClipboardStagingRoot(tempRoot: string): Promise { + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + await mkdir(stagingRoot, { recursive: true, mode: 0o700 }) + const rootStats = await lstat(stagingRoot) + if (!isSafeOwnedDirectory(rootStats)) { + throw new Error('Remote clipboard staging root is unsafe') + } + return stagingRoot +} + +async function sweepDirectories( + root: string, + nowMs: number, + ownsEntry: (name: string) => boolean, + entryLimit = Number.POSITIVE_INFINITY +): Promise<{ complete: boolean; hasFailures: boolean; hasFreshDirectories: boolean }> { + let rootDir: Dir + try { + rootDir = await opendir(root) + } catch { + return { complete: false, hasFailures: false, hasFreshDirectories: false } + } + + let complete = true + let entriesVisited = 0 + let hasFailures = false + let hasFreshDirectories = false + const pending = new Set>() + try { + for await (const entry of rootDir) { + entriesVisited += 1 + if (entry.isDirectory() && ownsEntry(entry.name)) { + const candidate = join(root, entry.name) + if (isDirectChild(root, candidate)) { + const cleanup = cleanupDirectory(candidate, nowMs).then((result) => { + hasFailures ||= result === 'failed' + hasFreshDirectories ||= result === 'fresh' + }) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { + await Promise.race(pending) + } + } + } + if (entriesVisited >= entryLimit) { + break + } + } + } catch { + complete = false + } finally { + await rootDir.close().catch(() => undefined) + } + await Promise.all(pending) + return { complete, hasFailures, hasFreshDirectories } +} + +async function cleanupDirectory(directory: string, nowMs: number): Promise { + try { + const directoryStats = await lstat(directory) + if (!isSafeOwnedDirectory(directoryStats)) { + return 'ignored' + } + if (nowMs - directoryStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { + return 'fresh' + } + await rm(directory, REMOVE_OPTIONS) + return 'removed' + } catch (error) { + return isMissingPathError(error) ? 'ignored' : 'failed' + } +} + +function isSafeOwnedDirectory(stats: Stats): boolean { + if (!stats.isDirectory() || stats.isSymbolicLink()) { + return false + } + if (typeof process.getuid !== 'function') { + return true + } + return stats.uid === process.getuid() && (stats.mode & 0o777) === 0o700 +} + +function isDirectChild(parent: string, candidate: string): boolean { + return dirname(resolve(candidate)) === resolve(parent) +} + +function isTransferDirectoryName(name: string): boolean { + return TRANSFER_DIRECTORY_PATTERN.test(name) +} + +function isLegacyTransferDirectoryName(name: string): boolean { + return LEGACY_DIRECTORY_PATTERN.test(name) +} + +function isMissingPathError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +}