diff --git a/src/main/window/clipboard-remote-file-copy.test.ts b/src/main/window/clipboard-remote-file-copy.test.ts new file mode 100644 index 00000000000..cca0200b43e --- /dev/null +++ b/src/main/window/clipboard-remote-file-copy.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { cleanupLegacyRemoteClipboardStagingMock } = vi.hoisted(() => ({ + cleanupLegacyRemoteClipboardStagingMock: vi.fn(async () => undefined) +})) + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: vi.fn() +})) +vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) +vi.mock('./clipboard-remote-file-staging', () => ({ + cleanupExpiredRemoteClipboardStaging: vi.fn(async () => undefined), + cleanupLegacyRemoteClipboardStaging: cleanupLegacyRemoteClipboardStagingMock, + createRemoteClipboardTransferDirectory: vi.fn(), + removeRemoteClipboardTransferDirectory: vi.fn(), + scheduleRemoteClipboardTransferCleanup: vi.fn() +})) + +import { scheduleLegacyRemoteClipboardFileCleanup } from './clipboard-remote-file-copy' + +describe('legacy remote clipboard cleanup scheduling', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('runs the shared-root compatibility scan once after the startup delay', async () => { + vi.useFakeTimers() + vi.spyOn(Date, 'now').mockReturnValue(1_760_000_000_000) + + scheduleLegacyRemoteClipboardFileCleanup() + scheduleLegacyRemoteClipboardFileCleanup() + await vi.advanceTimersByTimeAsync(29_999) + + expect(cleanupLegacyRemoteClipboardStagingMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledOnce() + expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledWith('/tmp', 1_760_000_000_000) + }) +}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 965d894e06f..f5cd1af3f38 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -15,13 +15,12 @@ import { cleanupExpiredRemoteClipboardStaging, cleanupLegacyRemoteClipboardStaging, createRemoteClipboardTransferDirectory, - removeRemoteClipboardTransferDirectory + removeRemoteClipboardTransferDirectory, + scheduleRemoteClipboardTransferCleanup } from './clipboard-remote-file-staging' type RemoteClipboardFileDeps = Omit -const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 -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(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) @@ -73,7 +72,7 @@ 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(tempRoot, tempDir) + scheduleRemoteClipboardTransferCleanup(tempRoot, tempDir) } return result } finally { @@ -114,21 +113,6 @@ function sanitizeLocalClipboardFilename(remoteBasename: string): string { return sanitized } -function scheduleRemoteClipboardFileCleanup( - tempRoot: string, - tempDir: string, - delayMs = REMOTE_CLIPBOARD_FILE_TTL_MS -): void { - const timer = setTimeout(() => { - 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-fs.test.ts b/src/main/window/clipboard-remote-file-staging-fs.test.ts new file mode 100644 index 00000000000..3e042b3790d --- /dev/null +++ b/src/main/window/clipboard-remote-file-staging-fs.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { access, lstat, mkdir, mkdtemp, readdir, rm, symlink, utimes } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' + +import { + cleanupExpiredRemoteClipboardStaging, + createRemoteClipboardTransferDirectory, + getRemoteClipboardStagingRoot +} from './clipboard-remote-file-staging' + +const FIXTURE_PREFIX = 'orca-clipboard-staging-test-' +const NOW_MS = 1_760_000_000_000 +const fixtures: string[] = [] + +afterEach(async () => { + await Promise.all( + fixtures.splice(0).map(async (fixture) => { + expect(dirname(fixture)).toBe(tmpdir()) + expect(basename(fixture)).toMatch(new RegExp(`^${FIXTURE_PREFIX}`)) + await rm(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }) + }) + ) +}) + +describe('remote clipboard staging filesystem contract', () => { + it('removes only expired owned transfers with real filesystem metadata', async () => { + const tempRoot = await makeFixture() + const foreignDirectory = join(tempRoot, 'foreign-directory') + await mkdir(foreignDirectory) + const expiredTransfer = await createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS - 2 * 60 * 60 * 1000, + '00000000-0000-4000-8000-000000000000' + ) + const freshTransfer = await createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000001' + ) + const expiredAt = new Date(NOW_MS - 2 * 60 * 60 * 1000) + await utimes(expiredTransfer, expiredAt, expiredAt) + + await cleanupExpiredRemoteClipboardStaging(tempRoot, NOW_MS) + + await expect(access(expiredTransfer)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(freshTransfer)).resolves.toBeUndefined() + await expect(access(foreignDirectory)).resolves.toBeUndefined() + if (typeof process.getuid === 'function') { + const rootStats = await lstat(getRemoteClipboardStagingRoot(tempRoot)) + expect(rootStats.uid).toBe(process.getuid()) + expect(rootStats.mode & 0o777).toBe(0o700) + } + }) + + it('rejects a real staging-root symlink without writing through it', async () => { + const tempRoot = await makeFixture() + const outsideDirectory = join(tempRoot, 'outside') + const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) + await mkdir(outsideDirectory) + await symlink(outsideDirectory, stagingRoot, process.platform === 'win32' ? 'junction' : 'dir') + + await expect( + createRemoteClipboardTransferDirectory( + tempRoot, + NOW_MS, + '00000000-0000-4000-8000-000000000000' + ) + ).rejects.toThrow('staging root is unsafe') + + expect(await readdir(outsideDirectory)).toEqual([]) + }) +}) + +async function makeFixture(): Promise { + const fixture = await mkdtemp(join(tmpdir(), FIXTURE_PREFIX)) + fixtures.push(fixture) + return fixture +} diff --git a/src/main/window/clipboard-remote-file-staging.test.ts b/src/main/window/clipboard-remote-file-staging.test.ts index b56de7be665..9013bcd6188 100644 --- a/src/main/window/clipboard-remote-file-staging.test.ts +++ b/src/main/window/clipboard-remote-file-staging.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join, resolve } from 'node:path' const { accessMock, lstatMock, mkdirMock, opendirMock, rmMock, writeFileMock } = vi.hoisted(() => ({ @@ -24,10 +24,12 @@ import { cleanupLegacyRemoteClipboardStaging, createRemoteClipboardTransferDirectory, getRemoteClipboardStagingRoot, - removeRemoteClipboardTransferDirectory + removeRemoteClipboardTransferDirectory, + scheduleRemoteClipboardTransferCleanup } from './clipboard-remote-file-staging' const TTL_MS = 60 * 60 * 1000 +const RETRY_MS = 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()}` : '' @@ -83,6 +85,10 @@ describe('remote clipboard staging ownership', () => { writeFileMock.mockResolvedValue(undefined) }) + afterEach(() => { + vi.useRealTimers() + }) + it('creates a private owned parent and a per-transfer child', async () => { const result = await createRemoteClipboardTransferDirectory(TEMP_ROOT, NOW_MS, UUID_A) @@ -215,6 +221,17 @@ describe('remote clipboard staging ownership', () => { expect(rmMock).toHaveBeenCalledTimes(4) }) + it('bounds locked-file timer retries', async () => { + vi.useFakeTimers() + rmMock.mockRejectedValue(Object.assign(new Error('locked'), { code: 'EPERM' })) + + scheduleRemoteClipboardTransferCleanup(TEMP_ROOT, join(STAGING_ROOT, EXPIRED_TRANSFER)) + await vi.advanceTimersByTimeAsync(TTL_MS + 4 * RETRY_MS) + + expect(rmMock).toHaveBeenCalledTimes(4) + expect(vi.getTimerCount()).toBe(0) + }) + 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')}`) diff --git a/src/main/window/clipboard-remote-file-staging.ts b/src/main/window/clipboard-remote-file-staging.ts index 911dc94077d..47da6028a3a 100644 --- a/src/main/window/clipboard-remote-file-staging.ts +++ b/src/main/window/clipboard-remote-file-staging.ts @@ -7,6 +7,8 @@ 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 +const REMOTE_CLIPBOARD_CLEANUP_RETRY_MS = 60 * 1000 +const REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT = 3 // 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}' @@ -119,6 +121,41 @@ export async function removeRemoteClipboardTransferDirectory( } } +export function scheduleRemoteClipboardTransferCleanup( + tempRoot: string, + transferDirectory: string +): void { + scheduleCleanupAttempt( + tempRoot, + transferDirectory, + REMOTE_CLIPBOARD_FILE_TTL_MS, + REMOTE_CLIPBOARD_CLEANUP_RETRY_LIMIT + ) +} + +function scheduleCleanupAttempt( + tempRoot: string, + transferDirectory: string, + delayMs: number, + retriesRemaining: number +): void { + const timer = setTimeout(() => { + void removeRemoteClipboardTransferDirectory(tempRoot, transferDirectory).then((removed) => { + if (!removed && retriesRemaining > 0) { + scheduleCleanupAttempt( + tempRoot, + transferDirectory, + REMOTE_CLIPBOARD_CLEANUP_RETRY_MS, + retriesRemaining - 1 + ) + } + }) + }, delayMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } +} + async function ensureRemoteClipboardStagingRoot(tempRoot: string): Promise { const stagingRoot = getRemoteClipboardStagingRoot(tempRoot) await mkdir(stagingRoot, { recursive: true, mode: 0o700 })