mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
perf(clipboard): isolate remote staging cleanup
This commit is contained in:
@@ -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()
|
||||
}))
|
||||
|
||||
|
||||
@@ -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<string, (...args: unknown[]) => unknown> {
|
||||
const handlers = new Map<string, (...args: unknown[]) => 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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
const batchSize = 128
|
||||
for (let start = 0; start < count; start += batchSize) {
|
||||
const tasks: Promise<unknown>[] = []
|
||||
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<number> {
|
||||
return (await readdir(root)).filter((name) => name.startsWith('foreign-')).length
|
||||
}
|
||||
|
||||
async function scanSharedTempRoot(root: string): Promise<void> {
|
||||
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<void>): Promise<number> {
|
||||
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) }
|
||||
}
|
||||
@@ -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<void>((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()
|
||||
})
|
||||
})
|
||||
@@ -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<ClipboardFileDeps, 'resolveFilePath'>
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
await cleanupLegacyRemoteClipboardStaging(app.getPath('temp'), nowMs)
|
||||
}
|
||||
|
||||
export function scheduleLegacyRemoteClipboardFileCleanup(): void {
|
||||
if (legacyCleanupScheduled) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = new Set<Promise<void>>()
|
||||
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<void> {
|
||||
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<typeof setTimeout>): void {
|
||||
if (typeof timer === 'object' && 'unref' in timer) {
|
||||
timer.unref()
|
||||
}
|
||||
|
||||
@@ -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<MockDirent>): {
|
||||
[Symbol.asyncIterator]: () => AsyncGenerator<MockDirent>
|
||||
close: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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<void>((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<MockDirent> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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<Promise<void>>()
|
||||
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<CleanupResult> {
|
||||
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'
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user