Files
orca/src/main/runtime/orca-runtime-files.test.ts
T
33650832e4 fix: prevent relay fs.rename from clobbering an existing destination (#2926) (#2937)
* fix: prevent relay fs.rename from clobbering an existing destination

The remote file-explorer rename path (provider.rename -> relay
fs.rename) called fs.rename unconditionally, silently overwriting any
file/folder already at the destination. The local rename already
guards via assertFileExplorerRenameDestinationAvailable; apply the
same guard on the relay to restore local/remote parity (case-only
renames on case-insensitive filesystems still allowed).

Moved the collision helper from src/main to src/shared so both the
main process and the remotely-deployed relay share one implementation.

Closes #2926

* review: make SSH safe rename explicit

- keep relay fs.rename raw and add fs.renameNoClobber for user-facing renames
- route SSH file-explorer rename paths through renameNoClobber
- add relay/provider/runtime regression coverage and stale-relay fail-closed handling
- verified live SSH rename behavior on openclaw 2

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-05-28 16:14:57 -04:00

334 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as Fs from 'fs'
import type * as FsPromises from 'fs/promises'
import type * as FilesystemAuth from '../ipc/filesystem-auth'
const {
lstatMock,
renameMock,
resolveAuthorizedPathMock,
statMock,
subscribeParcelWatcherMock,
watchMock
} = vi.hoisted(() => ({
lstatMock: vi.fn(),
renameMock: vi.fn(),
resolveAuthorizedPathMock: vi.fn(),
statMock: vi.fn(),
subscribeParcelWatcherMock: vi.fn(),
watchMock: vi.fn()
}))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
return {
...actual,
watch: watchMock
}
})
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
return {
...actual,
lstat: lstatMock,
rename: renameMock,
stat: statMock
}
})
vi.mock('@parcel/watcher', () => ({
subscribe: subscribeParcelWatcherMock
}))
vi.mock('../ipc/filesystem-auth', async () => {
const actual = await vi.importActual<typeof FilesystemAuth>('../ipc/filesystem-auth')
return {
...actual,
resolveAuthorizedPath: resolveAuthorizedPathMock
}
})
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
}))
import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
function enoent(): Error {
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
function mockStats(dev: number, ino: number) {
return { dev, ino, isDirectory: () => false }
}
function createRuntimeFileCommands() {
const store = {
getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined)
}
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: '/repo'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
} as never)
return { commands, store }
}
describe('RuntimeFileCommands', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.useFakeTimers()
lstatMock.mockReset()
renameMock.mockReset()
resolveAuthorizedPathMock.mockReset()
statMock.mockReset()
subscribeParcelWatcherMock.mockReset()
watchMock.mockReset()
lstatMock.mockRejectedValue(enoent())
renameMock.mockResolvedValue(undefined)
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
})
afterEach(async () => {
await awaitRuntimeFileWatcherUnsubscribes()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
vi.useRealTimers()
})
it('opens source control diffs through the renderer host', async () => {
const openDiff = vi.fn()
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => ({ getRepo: vi.fn(() => undefined) }),
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: '/repo'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn(),
openDiff
} as never)
const result = await commands.openMobileDiff('id:wt-1', 'docs/readme.md', true)
expect(openDiff).toHaveBeenCalledWith('wt-1', '/repo/docs/readme.md', 'docs/readme.md', true)
expect(result).toEqual({
worktree: 'wt-1',
relativePath: 'docs/readme.md',
kind: 'markdown',
opened: true
})
})
it('renames a runtime-local file when destination does not exist', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')
expect(renameMock).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
})
it('allows runtime-local case-only rename with IPC parity guard behavior', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/README.md' || p === '/repo/readme.md') {
return mockStats(10, 100)
}
throw enoent()
})
await commands.renameFileExplorerPath('id:wt-1', 'README.md', 'readme.md')
expect(renameMock).toHaveBeenCalledWith('/repo/README.md', '/repo/readme.md')
})
it('rejects runtime-local true destination collisions', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/old.ts') {
return mockStats(11, 110)
}
if (p === '/repo/new.ts') {
return mockStats(11, 111)
}
throw enoent()
})
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
"A file or folder named 'new.ts' already exists in this location"
)
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects runtime-local hard-link alias collisions', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/README.md' || p === '/repo/README-hardlink.md') {
return mockStats(12, 120)
}
throw enoent()
})
await expect(
commands.renameFileExplorerPath('id:wt-1', 'README.md', 'README-hardlink.md')
).rejects.toThrow("A file or folder named 'README-hardlink.md' already exists in this location")
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects runtime-local cross-parent case-only collisions', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/src/README.md' || p === '/repo/docs/readme.md') {
return mockStats(13, 130)
}
throw enoent()
})
await expect(
commands.renameFileExplorerPath('id:wt-1', 'src/README.md', 'docs/readme.md')
).rejects.toThrow("A file or folder named 'readme.md' already exists in this location")
expect(renameMock).not.toHaveBeenCalled()
})
it('routes runtime remote rename through the SSH no-clobber provider method', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
await commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')
expect(renameNoClobber).toHaveBeenCalledWith('/repo/old.ts', '/repo/new.ts')
expect(renameMock).not.toHaveBeenCalled()
})
it('propagates runtime remote no-clobber rename failures', async () => {
const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists'))
vi.mocked(getSshFilesystemProvider).mockReturnValue({ renameNoClobber } as never)
const { commands, store } = createRuntimeFileCommands()
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
'destination exists'
)
expect(renameMock).not.toHaveBeenCalled()
})
it('uses a conservative Node watcher for Windows runtime file watches', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const store = { getRepo: vi.fn(() => undefined) }
const close = vi.fn()
const on = vi.fn()
let listener: (() => void) | null = null
watchMock.mockImplementation((_rootPath, _options, callback) => {
listener = callback
return { close, on }
})
resolveAuthorizedPathMock.mockResolvedValue('C:\\repo')
statMock.mockResolvedValue({ isDirectory: () => true })
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: 'C:\\repo'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
} as never)
const onEvents = vi.fn()
const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents)
expect(watchMock).toHaveBeenCalledWith('C:\\repo', { recursive: true }, expect.any(Function))
const emit = listener as (() => void) | null
expect(emit).not.toBeNull()
emit?.()
emit?.()
await vi.advanceTimersByTimeAsync(149)
expect(onEvents).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(onEvents).toHaveBeenCalledTimes(1)
expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: 'C:\\repo' }])
unsubscribe()
expect(close).toHaveBeenCalledTimes(1)
})
it('tracks native Parcel watcher unsubscribe work so shutdown can await it', async () => {
const store = { getRepo: vi.fn(() => undefined) }
resolveAuthorizedPathMock.mockResolvedValue('/repo')
statMock.mockResolvedValue({ isDirectory: () => true })
let resolveUnsubscribe: () => void = () => {}
const unsubscribeMock = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUnsubscribe = resolve
})
)
subscribeParcelWatcherMock.mockResolvedValue({ unsubscribe: unsubscribeMock })
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: '/repo'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
} as never)
const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn())
unsubscribe()
let drained = false
const drainPromise = awaitRuntimeFileWatcherUnsubscribes().then(() => {
drained = true
})
await Promise.resolve()
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
expect(drained).toBe(false)
resolveUnsubscribe()
await drainPromise
expect(drained).toBe(true)
})
})