mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
* 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>
334 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|