Files
orca/src/main/ipc/filesystem-import.test.ts
T

782 lines
24 KiB
TypeScript

/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing,
symlink safety, and runtime-upload staging against one shared IPC fixture. */
import path from 'path'
import { constants } from 'fs'
import { Readable, Writable } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
const {
handleMock,
lstatMock,
mkdirMock,
realpathMock,
copyFileMock,
openMock,
readFileMock,
readdirMock,
rmMock,
unlinkMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
openMock: vi.fn(),
readFileMock: vi.fn(),
readdirMock: vi.fn(),
rmMock: vi.fn(),
unlinkMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
}))
vi.mock('fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
open: openMock,
rename: vi.fn(),
writeFile: vi.fn(),
realpath: realpathMock,
copyFile: copyFileMock,
readFile: readFileMock,
readdir: readdirMock,
rm: rmMock,
unlink: unlinkMock
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
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' })
}
describe('fs:importExternalPaths', () => {
const destDir = path.resolve('/workspace/repo/src')
function mockSourceFile(filePath: string): void {
const resolvedPath = path.resolve(filePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
size: 12,
ino: 1,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
}
function mockSourceDir(dirPath: string, entries: { name: string; isDir: boolean }[]): void {
const resolvedDir = path.resolve(dirPath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedDir) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
const entry = entries.find((e) => path.join(resolvedDir, e.name) === p)
if (entry) {
return {
size: entry.isDir ? 0 : 12,
ino: entry.isDir ? 2 : 3,
dev: 1,
isFile: () => !entry.isDir,
isDirectory: () => entry.isDir,
isSymbolicLink: () => false
}
}
throw enoent()
})
readdirMock.mockImplementation(async () => {
return entries.map((e) => ({
name: e.name,
isDirectory: () => e.isDir,
isSymbolicLink: () => false,
isFile: () => !e.isDir
}))
})
}
function mockSymlinkSource(filePath: string): void {
const resolvedPath = path.resolve(filePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
}
throw enoent()
})
}
function mockLocalCopyOpenSuccess(content = Buffer.from('file-content')): void {
openMock.mockImplementation(async (_p: string, flags: unknown) => {
if (flags === 'wx') {
const written: Buffer[] = []
return {
createWriteStream: () =>
new Writable({
write(chunk, _encoding, callback) {
written.push(Buffer.from(chunk))
callback()
}
}),
close: vi.fn().mockResolvedValue(undefined),
written
}
}
return {
stat: vi.fn().mockResolvedValue({
size: content.byteLength,
ino: 1,
dev: 1,
isFile: () => true
}),
createReadStream: () => Readable.from([content]),
close: vi.fn().mockResolvedValue(undefined)
}
})
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
lstatMock.mockReset()
mkdirMock.mockReset()
realpathMock.mockReset()
copyFileMock.mockReset()
openMock.mockReset()
readFileMock.mockReset()
readdirMock.mockReset()
rmMock.mockReset()
unlinkMock.mockReset()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
})
realpathMock.mockImplementation(async (p: string) => p)
lstatMock.mockRejectedValue(enoent())
mkdirMock.mockResolvedValue(undefined)
copyFileMock.mockResolvedValue(undefined)
mockLocalCopyOpenSuccess()
readFileMock.mockResolvedValue(Buffer.from('file-content'))
readdirMock.mockResolvedValue([])
rmMock.mockResolvedValue(undefined)
unlinkMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
it('imports a single file', async () => {
const sourcePath = '/tmp/dropped/logo.png'
mockSourceFile(sourcePath)
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: unknown[] }
expect(result.results).toHaveLength(1)
expect(result.results[0]).toMatchObject({
status: 'imported',
kind: 'file',
renamed: false,
destPath: path.join(destDir, 'logo.png')
})
expect(openMock).toHaveBeenCalledWith(
path.resolve(sourcePath),
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
expect(copyFileMock).not.toHaveBeenCalled()
})
it('fails local import instead of clobbering when the chosen destination appears late', async () => {
const sourcePath = '/tmp/dropped/logo.png'
mockSourceFile(sourcePath)
openMock.mockImplementation(async (_p: string, flags: unknown) => {
if (flags === 'wx') {
throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' })
}
return {
stat: vi.fn().mockResolvedValue({
size: 12,
ino: 1,
dev: 1,
isFile: () => true
}),
createReadStream: () => Readable.from([Buffer.from('file-content')]),
close: vi.fn().mockResolvedValue(undefined)
}
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({ status: 'failed', reason: 'EEXIST' })
expect(openMock).toHaveBeenCalledWith(
path.resolve(sourcePath),
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
})
it('imports multiple files in one batch', async () => {
const sources = ['/tmp/dropped/a.txt', '/tmp/dropped/b.txt']
lstatMock.mockImplementation(async (p: string) => {
const resolved = [path.resolve(sources[0]), path.resolve(sources[1])]
if (resolved.includes(p)) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: sources,
destDir
})) as { results: { status: string }[] }
expect(result.results).toHaveLength(2)
expect(result.results[0]).toMatchObject({ status: 'imported' })
expect(result.results[1]).toMatchObject({ status: 'imported' })
})
it('imports a directory recursively', async () => {
const sourcePath = '/tmp/dropped/assets'
mockSourceDir(sourcePath, [
{ name: 'icon.png', isDir: false },
{ name: 'fonts', isDir: true }
])
readdirMock
.mockResolvedValueOnce([
{
name: 'icon.png',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
},
{ name: 'fonts', isDirectory: () => true, isSymbolicLink: () => false, isFile: () => false }
])
.mockResolvedValue([])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; kind?: string }[] }
expect(result.results).toHaveLength(1)
expect(result.results[0]).toMatchObject({
status: 'imported',
kind: 'directory',
renamed: false
})
expect(mkdirMock).toHaveBeenCalled()
})
it('deconflicts top-level filename collisions', async () => {
const sourcePath = '/tmp/dropped/logo.png'
const existingDest = path.join(destDir, 'logo.png')
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sourcePath)) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
if (p === existingDest) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; destPath?: string; renamed?: boolean }[] }
expect(result.results[0]).toMatchObject({
status: 'imported',
destPath: path.join(destDir, 'logo copy.png'),
renamed: true
})
})
it('deconflicts top-level directory collisions', async () => {
const sourcePath = '/tmp/dropped/assets'
const existingDest = path.join(destDir, 'assets')
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sourcePath)) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
if (p === existingDest) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
throw enoent()
})
readdirMock.mockResolvedValue([])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; destPath?: string; renamed?: boolean }[] }
expect(result.results[0]).toMatchObject({
status: 'imported',
destPath: path.join(destDir, 'assets copy'),
renamed: true
})
})
it('rejects top-level symlink sources before canonicalization', async () => {
const sourcePath = '/tmp/dropped/link.txt'
mockSymlinkSource(sourcePath)
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
expect(copyFileMock).not.toHaveBeenCalled()
})
it('skips a dropped directory with nested symlinks without leaving partial output', async () => {
const sourcePath = '/tmp/dropped/mixeddir'
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sourcePath)) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
throw enoent()
})
readdirMock.mockResolvedValue([
{
name: 'normal.txt',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
},
{
name: 'bad-link',
isDirectory: () => false,
isSymbolicLink: () => true,
isFile: () => false
}
])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
expect(copyFileMock).not.toHaveBeenCalled()
})
it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => {
const sourcePath = '/tmp/dropped/mixeddir'
const resolvedSource = path.resolve(sourcePath)
const childPath = path.join(resolvedSource, 'normal.txt')
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedSource) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
if (p === childPath) {
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
}
throw enoent()
})
readdirMock.mockResolvedValue([
{
name: 'normal.txt',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
}
])
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir
})) as { results: { status: string; reason?: string }[] }
expect(result.results[0]).toMatchObject({
status: 'failed',
reason: "Symlink not allowed in 'normal.txt'"
})
expect(openMock).not.toHaveBeenCalledWith(childPath, expect.anything())
expect(rmMock).toHaveBeenCalledWith(path.join(destDir, 'mixeddir'), {
recursive: true,
force: true
})
})
it('rejects unauthorized destinations', async () => {
const sourcePath = '/tmp/dropped/file.txt'
mockSourceFile(sourcePath)
realpathMock.mockImplementation(async (p: string) => {
if (p === path.resolve('/outside/evil')) {
return path.resolve('/outside/evil')
}
return p
})
await expect(
handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: [sourcePath],
destDir: '/outside/evil'
})
).rejects.toThrow('Access denied')
})
it('returns per-item results including rename metadata', async () => {
const sources = ['/tmp/dropped/a.txt', '/tmp/dropped/missing.txt']
lstatMock.mockImplementation(async (p: string) => {
if (p === path.resolve(sources[0])) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
throw enoent()
})
const result = (await handlers.get('fs:importExternalPaths')!(null, {
sourcePaths: sources,
destDir
})) as {
results: {
sourcePath: string
status: string
reason?: string
renamed?: boolean
}[]
}
expect(result.results).toHaveLength(2)
expect(result.results[0]).toMatchObject({
sourcePath: sources[0],
status: 'imported',
renamed: false
})
expect(result.results[1]).toMatchObject({
sourcePath: sources[1],
status: 'skipped',
reason: 'missing'
})
})
it('stages external files for runtime upload without copying into the local worktree', async () => {
const sourcePath = '/tmp/dropped/logo.png'
const resolvedPath = path.resolve(sourcePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
size: 4,
ino: 1,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
const closeMock = vi.fn().mockResolvedValue(undefined)
const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
openMock.mockResolvedValue({
stat: vi.fn().mockResolvedValue({
size: 4,
ino: 1,
dev: 1,
isFile: () => true
}),
readFile: readFileHandleMock,
close: closeMock
})
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: unknown[] }
expect(result.sources).toEqual([
{
sourcePath,
status: 'staged',
name: 'logo.png',
kind: 'file',
entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }]
}
])
expect(copyFileMock).not.toHaveBeenCalled()
expect(readFileHandleMock).toHaveBeenCalled()
expect(closeMock).toHaveBeenCalled()
})
it('stages runtime upload directories whose child names start with dotdot characters', async () => {
const sourcePath = '/tmp/dropped/project'
const resolvedPath = path.resolve(sourcePath)
const childDir = path.join(resolvedPath, '..assets')
const childFile = path.join(childDir, 'icon.txt')
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath || p === childDir) {
return {
size: 0,
ino: 1,
dev: 1,
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
}
}
if (p === childFile) {
return {
size: 4,
ino: 2,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
readdirMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return [
{
name: '..assets',
isDirectory: () => true,
isSymbolicLink: () => false,
isFile: () => false
}
]
}
if (p === childDir) {
return [
{
name: 'icon.txt',
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
}
]
}
return []
})
openMock.mockResolvedValue({
stat: vi.fn().mockResolvedValue({
size: 4,
ino: 2,
dev: 1,
isFile: () => true
}),
readFile: vi.fn().mockResolvedValue(Buffer.from('icon')),
close: vi.fn().mockResolvedValue(undefined)
})
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: unknown[] }
expect(result.sources).toEqual([
{
sourcePath,
status: 'staged',
name: 'project',
kind: 'directory',
entries: [
{ relativePath: '', kind: 'directory' },
{ relativePath: '..assets', kind: 'directory' },
{ relativePath: '..assets/icon.txt', kind: 'file', contentBase64: 'aWNvbg==' }
]
}
])
})
it('skips runtime upload directories with nested symlinks during the staging traversal', async () => {
const sourcePath = '/tmp/dropped/project'
const resolvedPath = path.resolve(sourcePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
size: 0,
ino: 1,
dev: 1,
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
}
}
throw enoent()
})
readdirMock.mockResolvedValue([
{
name: 'link.txt',
isDirectory: () => false,
isSymbolicLink: () => true,
isFile: () => false
}
])
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: { status: string; reason?: string }[] }
expect(result.sources[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
expect(readdirMock).toHaveBeenCalledOnce()
expect(openMock).not.toHaveBeenCalled()
})
it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => {
const sourcePath = '/tmp/dropped/project'
const resolvedPath = path.resolve(sourcePath)
const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) =>
path.join(resolvedPath, name)
)
const mib = 1024 * 1024
const regularSize = 25 * mib
const overflowSize = 1 * mib
const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk'))
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
size: 0,
ino: 1,
dev: 1,
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
}
}
const fileIndex = filePaths.indexOf(p)
if (fileIndex !== -1) {
const size = fileIndex === filePaths.length - 1 ? overflowSize : regularSize
return {
size,
ino: fileIndex + 2,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
readdirMock.mockResolvedValue(
filePaths.map((filePath) => ({
name: path.basename(filePath),
isDirectory: () => false,
isSymbolicLink: () => false,
isFile: () => true
}))
)
openMock.mockImplementation(async (p: string) => {
const fileIndex = filePaths.indexOf(p)
if (fileIndex >= 0 && fileIndex < filePaths.length - 1) {
return {
stat: vi.fn().mockResolvedValue({
size: regularSize,
ino: fileIndex + 2,
dev: 1,
isFile: () => true
}),
readFile: readFileMock,
close: vi.fn().mockResolvedValue(undefined)
}
}
throw new Error(`unexpected open: ${p}`)
})
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: { status: string; reason?: string }[] }
expect(result.sources[0]).toMatchObject({
status: 'failed',
reason: 'Remote import is too large'
})
expect(readFileMock).toHaveBeenCalledTimes(4)
expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything())
})
it('fails runtime upload staging when a file changes between lstat and open', async () => {
const sourcePath = '/tmp/dropped/logo.png'
const resolvedPath = path.resolve(sourcePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
size: 4,
ino: 1,
dev: 1,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
}
throw enoent()
})
const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
openMock.mockResolvedValue({
stat: vi.fn().mockResolvedValue({
size: 4,
ino: 2,
dev: 1,
isFile: () => true
}),
readFile: readFileHandleMock,
close: vi.fn().mockResolvedValue(undefined)
})
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: { status: string; reason?: string }[] }
expect(result.sources[0]).toMatchObject({
status: 'failed',
reason: "File changed during upload staging: ''"
})
expect(readFileHandleMock).not.toHaveBeenCalled()
})
it('fails runtime upload staging when a checked directory resolves outside the upload root', async () => {
const sourcePath = '/tmp/dropped/assets'
const resolvedPath = path.resolve(sourcePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
return {
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
}
}
throw enoent()
})
readdirMock.mockResolvedValue([])
realpathMock
.mockResolvedValueOnce(resolvedPath)
.mockResolvedValueOnce(path.resolve('/private/assets'))
const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
sourcePaths: [sourcePath]
})) as { sources: { status: string; reason?: string }[] }
expect(result.sources[0]).toMatchObject({
status: 'failed',
reason: "Path escaped upload root during staging: ''"
})
})
})