Files
orca/src/main/ipc/filesystem-mutations.test.ts
T
OrcaWinandm4air c5224f70d8 refactor(providers): delete the SSH git and filesystem providers and relay-only wiring (#27644)
* refactor(providers): delete the SSH git and filesystem providers and relay-only wiring

* refactor(ssh): drop relay-protocol exports only the deleted stream readers used

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 17:48:43 -07:00

469 lines
16 KiB
TypeScript

import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
copyFileMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
renameMock: vi.fn(),
writeFileMock: vi.fn(),
realpathMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
vi.mock('fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
rename: renameMock,
writeFile: writeFileMock,
realpath: realpathMock,
copyFile: copyFileMock,
readdir: vi.fn()
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import {
resetSshConnectionGenerations,
setSshConnectionGeneration
} from '../ssh/ssh-connection-generation'
// Why: paths are resolved via path.resolve() in production code, so test
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
const REPO_PATH = path.resolve('/workspace/repo')
const WORKSPACE_DIR = path.resolve('/workspace')
const store = {
getRepos: () => [
{ id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 }
],
getSettings: () => ({ workspaceDir: WORKSPACE_DIR })
}
function enoent(): Error {
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
function mockRealpath(mapping: Record<string, string>) {
realpathMock.mockImplementation(async (p: string) => {
if (mapping[p]) {
return mapping[p]
}
return p
})
}
function mockStats(dev: number, ino: number) {
return { dev, ino, isDirectory: () => false }
}
describe('registerFilesystemMutationHandlers', () => {
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
copyFileMock.mockReset()
lstatMock.mockReset()
mkdirMock.mockReset()
renameMock.mockReset()
writeFileMock.mockReset()
realpathMock.mockReset()
resetSshConnectionGenerations()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
})
// By default, paths resolve to themselves and targets don't exist yet
realpathMock.mockImplementation(async (p: string) => p)
lstatMock.mockRejectedValue(enoent())
mkdirMock.mockResolvedValue(undefined)
writeFileMock.mockResolvedValue(undefined)
renameMock.mockResolvedValue(undefined)
copyFileMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
// ── fs:createFile ──────────────────────────────────────────────
it('creates an empty file and its parent directories', async () => {
const filePath = path.resolve('/workspace/repo/src/new.ts')
await handlers.get('fs:createFile')!(null, { filePath })
expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo/src'), { recursive: true })
expect(writeFileMock).toHaveBeenCalledWith(filePath, '', {
encoding: 'utf-8',
flag: 'wx'
})
})
it('rejects file creation when path already exists (wx flag)', async () => {
// The wx flag causes writeFile to throw EEXIST atomically, without a
// separate lstat check — no TOCTOU race.
writeFileMock.mockRejectedValue(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }))
await expect(
handlers.get('fs:createFile')!(null, {
filePath: path.resolve('/workspace/repo/existing.ts')
})
).rejects.toThrow("A file or folder named 'existing.ts' already exists in this location")
})
it('rejects file creation outside allowed roots', async () => {
mockRealpath({
[path.resolve('/workspace/repo/link.ts')]: path.resolve('/private/secret.ts')
})
await expect(
handlers.get('fs:createFile')!(null, { filePath: path.resolve('/workspace/repo/link.ts') })
).rejects.toThrow('Access denied')
expect(writeFileMock).not.toHaveBeenCalled()
})
// ── fs:createDir ───────────────────────────────────────────────
it('creates a directory recursively', async () => {
const dirPath = path.resolve('/workspace/repo/src/components')
await handlers.get('fs:createDir')!(null, { dirPath })
expect(mkdirMock).toHaveBeenCalledWith(dirPath, { recursive: true })
})
it('rejects directory creation when path already exists', async () => {
lstatMock.mockResolvedValue({ isDirectory: () => true })
await expect(
handlers.get('fs:createDir')!(null, { dirPath: path.resolve('/workspace/repo/src') })
).rejects.toThrow("A file or folder named 'src' already exists in this location")
expect(mkdirMock).not.toHaveBeenCalled()
})
it('rejects directory creation outside allowed roots', async () => {
mockRealpath({
[path.resolve('/workspace/repo/escape')]: path.resolve('/etc/evil')
})
await expect(
handlers.get('fs:createDir')!(null, { dirPath: path.resolve('/workspace/repo/escape') })
).rejects.toThrow('Access denied')
expect(mkdirMock).not.toHaveBeenCalled()
})
// ── fs:rename ──────────────────────────────────────────────────
it('renames a file within the same directory', async () => {
const oldPath = path.resolve('/workspace/repo/old.ts')
const newPath = path.resolve('/workspace/repo/new.ts')
await handlers.get('fs:rename')!(null, { oldPath, newPath })
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('serializes concurrent local renames targeting the same destination', async () => {
const firstPath = path.resolve('/workspace/repo/first.ts')
const secondPath = path.resolve('/workspace/repo/second.ts')
const destinationPath = path.resolve('/workspace/repo/destination.ts')
let destinationExists = false
lstatMock.mockImplementation(async (filePath: string) => {
if (filePath === destinationPath && destinationExists) {
return mockStats(1, 30)
}
if (filePath === firstPath) {
return mockStats(1, 10)
}
if (filePath === secondPath) {
return mockStats(1, 20)
}
throw enoent()
})
renameMock.mockImplementation(async () => {
destinationExists = true
})
const results = await Promise.allSettled([
handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }),
handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath })
])
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
expect(renameMock).toHaveBeenCalledTimes(1)
})
it('rejects rename when destination already exists as a true collision', async () => {
const oldPath = path.resolve('/workspace/repo/old.ts')
const resolvedNewPath = path.resolve('/workspace/repo/new.ts')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath) {
return mockStats(1, 10)
}
if (p === resolvedNewPath) {
return mockStats(1, 11)
}
throw enoent()
})
await expect(
handlers.get('fs:rename')!(null, {
oldPath,
newPath: resolvedNewPath
})
).rejects.toThrow("A file or folder named 'new.ts' already exists in this location")
expect(renameMock).not.toHaveBeenCalled()
})
it('allows case-only rename when destination is the same entry in the same parent', async () => {
const oldPath = path.resolve('/workspace/repo/README.md')
const newPath = path.resolve('/workspace/repo/readme.md')
const canonicalPath = path.resolve('/workspace/repo/README.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(2, 20)
}
throw enoent()
})
mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath })
await handlers.get('fs:rename')!(null, { oldPath, newPath })
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('allows a native Unicode alias rename for the same directory entry', async () => {
const oldPath = path.resolve('/workspace/repo/Straße.md')
const newPath = path.resolve('/workspace/repo/STRASSE.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(2, 21)
}
throw enoent()
})
mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath })
await handlers.get('fs:rename')!(null, { oldPath, newPath })
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('rejects hard-link alias rename collisions even when dev and ino match', async () => {
const oldPath = path.resolve('/workspace/repo/README.md')
const newPath = path.resolve('/workspace/repo/README-hardlink.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(3, 30)
}
throw enoent()
})
await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toThrow(
"A file or folder named 'README-hardlink.md' already exists in this location"
)
expect(renameMock).not.toHaveBeenCalled()
})
it('fails closed when same-entry realpath encounters a permission error', async () => {
const oldPath = path.resolve('/workspace/repo/README.md')
const newPath = path.resolve('/workspace/repo/readme.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(3, 30)
}
throw enoent()
})
realpathMock.mockRejectedValue(
Object.assign(new Error('permission denied'), { code: 'EACCES' })
)
await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({
code: 'EACCES'
})
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => {
const oldPath = path.resolve('/workspace/repo/src/README.md')
const newPath = path.resolve('/workspace/repo/docs/readme.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(4, 40)
}
throw enoent()
})
await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toThrow(
"A file or folder named 'readme.md' already exists in this location"
)
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects rename when parent directory escapes allowed roots', async () => {
// Why: the parent is still canonicalized (preserveSymlink only preserves
// the leaf). A symlinked ancestor that points outside allowed roots must
// still be rejected so callers cannot redirect rename through it.
mockRealpath({
[path.resolve('/workspace/repo/escape-dir')]: path.resolve('/private/escape-dir')
})
await expect(
handlers.get('fs:rename')!(null, {
oldPath: path.resolve('/workspace/repo/old.ts'),
newPath: path.resolve('/workspace/repo/escape-dir/new.ts')
})
).rejects.toThrow('Access denied')
expect(renameMock).not.toHaveBeenCalled()
})
it('renames a symlink without following its target', async () => {
// Why: rename must operate on the symlink entry, not its target —
// following the link would rename the target file (possibly elsewhere in
// the worktree, or outside allowed roots entirely). Even though the
// symlink points at /private/secret.ts, renaming the link entry inside
// the allowed root is a safe directory-entry mutation.
mockRealpath({
[path.resolve('/workspace/repo/symlink.ts')]: path.resolve('/private/secret.ts')
})
const oldPath = path.resolve('/workspace/repo/symlink.ts')
const newPath = path.resolve('/workspace/repo/renamed-symlink.ts')
await handlers.get('fs:rename')!(null, { oldPath, newPath })
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('rejects direct SSH rename without target-bound generation provenance', async () => {
await expect(
handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-1'
})
).rejects.toThrow('SSH connection changed')
})
it('rejects equal-generation provenance for another direct SSH target', async () => {
await expect(
handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-b',
expectedSshTargetId: 'ssh-a',
expectedSshConnectionGeneration: 0
})
).rejects.toThrow('SSH connection changed')
})
it('rejects stale generation provenance for a direct SSH target', async () => {
setSshConnectionGeneration('ssh-1', 8)
await expect(
handlers.get('fs:rename')!(null, {
oldPath: '/home/me/repo/old.ts',
newPath: '/home/me/repo/new.ts',
connectionId: 'ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 7
})
).rejects.toThrow('SSH connection changed')
})
it('rejects stale SSH provenance when a direct mutation resolves local', async () => {
await expect(
handlers.get('fs:rename')!(null, {
oldPath: path.resolve('/workspace/repo/old.ts'),
newPath: path.resolve('/workspace/repo/new.ts'),
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 0
})
).rejects.toThrow('SSH connection changed')
expect(renameMock).not.toHaveBeenCalled()
})
it.each([
['fs:createFile', { filePath: path.resolve('/workspace/repo/new.ts') }],
['fs:createDir', { dirPath: path.resolve('/workspace/repo/new-dir') }],
[
'fs:rename',
{
oldPath: path.resolve('/workspace/repo/old.ts'),
newPath: path.resolve('/workspace/repo/new.ts')
}
],
[
'fs:copy',
{
sourcePath: path.resolve('/workspace/repo/source.ts'),
destinationPath: path.resolve('/workspace/repo/copy.ts')
}
],
[
'fs:importExternalPaths',
{ sourcePaths: [path.resolve('/tmp/source.ts')], destDir: path.resolve('/workspace/repo') }
],
[
'fs:resolveDroppedPathsForAgent',
{ paths: [path.resolve('/tmp/source.ts')], worktreePath: path.resolve('/workspace/repo') }
]
])(
'rejects %s before local fallback when the expected execution host is SSH',
async (channel, args) => {
await expect(
handlers.get(channel)!(null, { ...args, expectedExecutionHostId: 'ssh:ssh-1' })
).rejects.toThrow('Workspace host changed; refresh and try again')
expect(writeFileMock).not.toHaveBeenCalled()
expect(mkdirMock).not.toHaveBeenCalled()
expect(renameMock).not.toHaveBeenCalled()
expect(copyFileMock).not.toHaveBeenCalled()
}
)
// ── fs:copy ────────────────────────────────────────────────────
it('copies a file without overwriting an existing destination', async () => {
const sourcePath = path.resolve('/workspace/repo/source.ts')
const destinationPath = path.resolve('/workspace/repo/source copy.ts')
await handlers.get('fs:copy')!(null, { sourcePath, destinationPath })
expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo'), { recursive: true })
expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number))
})
// ── Edge cases ─────────────────────────────────────────────────
it('propagates non-ENOENT lstat errors in assertNotExists', async () => {
lstatMock.mockRejectedValue(new Error('EPERM: operation not permitted'))
await expect(
handlers.get('fs:createDir')!(null, { dirPath: path.resolve('/workspace/repo/locked') })
).rejects.toThrow('EPERM')
expect(mkdirMock).not.toHaveBeenCalled()
})
it('propagates mkdir permission errors for createFile', async () => {
mkdirMock.mockRejectedValue(new Error('EACCES: permission denied'))
await expect(
handlers.get('fs:createFile')!(null, {
filePath: path.resolve('/workspace/repo/nowrite/file.ts')
})
).rejects.toThrow('EACCES')
expect(writeFileMock).not.toHaveBeenCalled()
})
})