diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index 1efc9dff2..3046cbb62 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -1,5 +1,6 @@ import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' const handlers = new Map Promise>() const { @@ -9,11 +10,6 @@ const { realpathMock, copyFileMock, readdirMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -22,11 +18,6 @@ const { realpathMock: vi.fn(), copyFileMock: vi.fn(), readdirMock: vi.fn(), - sftpExistsMock: vi.fn(), - uploadFileMock: vi.fn(), - uploadDirMock: vi.fn(), - removeDirectorySftpMock: vi.fn(), - mkdirSftpMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -40,16 +31,13 @@ vi.mock('fs/promises', () => ({ copyFile: copyFileMock, readdir: readdirMock })) -vi.mock('../ssh/sftp-upload', () => ({ - sftpPathExists: sftpExistsMock, - uploadFile: uploadFileMock, - uploadDirectory: uploadDirMock, - removeDirectorySftp: removeDirectorySftpMock, - mkdirSftp: mkdirSftpMock -})) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' const store = { getRepos: () => [ @@ -65,13 +53,38 @@ const store = { } const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { + return { + readDir: vi.fn(), + readFile: vi.fn(), + downloadFile: vi.fn(), + openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileBase64: vi.fn(), + writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockRejectedValue(enoent()), + deletePath: vi.fn().mockResolvedValue(undefined), + createFile: vi.fn(), + createDir: vi.fn().mockResolvedValue(undefined), + createDirNoClobber: vi.fn().mockResolvedValue(undefined), + rename: vi.fn(), + renameNoClobber: vi.fn(), + copy: vi.fn(), + realpath: vi.fn(), + search: vi.fn(), + listFiles: vi.fn(), + watch: vi.fn() + } as unknown as IFilesystemProvider +} + describe('fs:importExternalPaths — SSH operations', () => { const destDir = '/home/user/project/src' const connId = 'ssh-conn-1' - const mockSftp = { end: vi.fn() } + let provider: IFilesystemProvider + let uploadSession: FileUploadSession const makeConn = () => ({ getState: () => ({ status: 'connected' }), - sftp: vi.fn().mockResolvedValue(mockSftp) + sftp: vi.fn() }) const mockDir = (p: string): void => { const rp = path.resolve(p) @@ -82,6 +95,22 @@ describe('fs:importExternalPaths — SSH operations', () => { throw enoent() }) } + const mockFile = (p: string): void => { + const rp = path.resolve(p) + lstatMock.mockImplementation(async (x: string) => { + if (x === rp) { + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + } const invoke = (args: Record) => handlers.get('fs:importExternalPaths')!(null, args) as Promise<{ results: Record[] @@ -96,37 +125,36 @@ describe('fs:importExternalPaths — SSH operations', () => { realpathMock, copyFileMock, readdirMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock ].forEach((m) => m.mockReset()) - mockSftp.end.mockReset() handleMock.mockImplementation((ch: string, h: never) => { handlers.set(ch, h) }) realpathMock.mockImplementation(async (p: string) => p) lstatMock.mockRejectedValue(enoent()) - sftpExistsMock.mockResolvedValue(false) - uploadFileMock.mockResolvedValue(undefined) - uploadDirMock.mockResolvedValue(undefined) - removeDirectorySftpMock.mockResolvedValue(undefined) - mkdirSftpMock.mockResolvedValue(undefined) + readdirMock.mockResolvedValue([]) getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) + uploadSession = { + uploadFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn() + } + provider = createProvider(uploadSession) + registerSshFilesystemProvider(connId, provider) registerFilesystemMutationHandlers(store as never) }) - it('deconflicts file names via SFTP lstat', async () => { - const rp = path.resolve('/tmp/dropped/logo.png') - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + afterEach(() => { + unregisterSshFilesystemProvider(connId) + }) + + it('deconflicts file names via provider stat', async () => { + mockFile('/tmp/dropped/logo.png') + vi.mocked(provider.stat).mockImplementation(async (p: string) => { + if (p === `${destDir}/logo.png`) { + return { type: 'file', size: 1, mtime: 1 } } throw enoent() }) - sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/logo.png`) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/logo.png'], destDir, @@ -157,66 +185,112 @@ describe('fs:importExternalPaths — SSH operations', () => { it('handles partial failure with correct per-item results', async () => { const sources = ['/tmp/dropped/good.txt', '/tmp/dropped/bad.txt', '/tmp/dropped/ok.txt'] + const resolved = new Set(sources.map((s) => path.resolve(s))) lstatMock.mockImplementation(async (p: string) => { - if (sources.map((s) => path.resolve(s)).includes(p)) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + if (resolved.has(p)) { + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } } throw enoent() }) - uploadFileMock.mockImplementation(async (_s: unknown, lp: string) => { - if (lp === path.resolve('/tmp/dropped/bad.txt')) { + vi.mocked(uploadSession.uploadFile).mockImplementation(async (_localPath, remotePath) => { + if (remotePath.endsWith('/bad.txt')) { throw new Error('permission denied') } }) + const { results } = await invoke({ sourcePaths: sources, destDir, connectionId: connId }) expect(results).toHaveLength(3) expect(results[0]).toMatchObject({ status: 'imported' }) expect(results[1]).toMatchObject({ status: 'failed', reason: 'permission denied' }) expect(results[2]).toMatchObject({ status: 'imported' }) + expect(provider.deletePath).not.toHaveBeenCalled() }) - it('uploads directories via mkdirSftp + uploadDirectory', async () => { - mockDir('/tmp/dropped/assets') - readdirMock.mockResolvedValue([]) + it('does not delete a file another client created during an exclusive-upload race', async () => { + mockFile('/tmp/dropped/report.txt') + vi.mocked(uploadSession.uploadFile).mockRejectedValue( + Object.assign(new Error('EEXIST: destination already exists'), { code: 'EEXIST' }) + ) + + const { results } = await invoke({ + sourcePaths: ['/tmp/dropped/report.txt'], + destDir, + connectionId: connId + }) + + expect(results[0]).toMatchObject({ + status: 'failed', + reason: 'EEXIST: destination already exists' + }) + // Why: a failed exclusive create does not prove Orca owns the destination. + expect(provider.deletePath).not.toHaveBeenCalled() + }) + + it('uploads directories via provider createDirNoClobber and binary writes', async () => { + const root = path.resolve('/tmp/dropped/assets') + const child = path.join(root, 'logo.png') + lstatMock.mockImplementation(async (p: string) => { + if (p === root) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + if (p === child) { + return { + size: 3, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockImplementation(async (p: string) => + p === root + ? [ + { + name: 'logo.png', + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + ] + : [] + ) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/assets'], destDir, connectionId: connId }) + expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, { - allowExisting: false + expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) + expect(uploadSession.uploadFile).toHaveBeenCalledWith(child, `${destDir}/assets/logo.png`, { + exclusive: true }) - expect(uploadDirMock).toHaveBeenCalledWith( - mockSftp, - path.resolve('/tmp/dropped/assets'), - `${destDir}/assets`, - path.resolve('/tmp/dropped/assets'), - { exclusive: true } - ) }) it('reports per-item failure when deconfliction throws', async () => { - const rp = path.resolve('/tmp/dropped/file.txt') - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - } - throw enoent() - }) - sftpExistsMock.mockRejectedValue(new Error('SFTP channel closed')) + mockFile('/tmp/dropped/file.txt') + vi.mocked(provider.stat).mockRejectedValue(new Error('Remote connection not found')) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'SFTP channel closed' }) + expect(results[0]).toMatchObject({ status: 'failed', reason: 'Remote connection not found' }) }) - it('reports failure when mkdirSftp rejects', async () => { + it('reports failure when creating a directory rejects', async () => { mockDir('/tmp/dropped/mydir') - readdirMock.mockResolvedValue([]) - mkdirSftpMock.mockRejectedValue(new Error('permission denied')) + vi.mocked(provider.createDirNoClobber).mockRejectedValue(new Error('permission denied')) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/mydir'], destDir, @@ -225,10 +299,38 @@ describe('fs:importExternalPaths — SSH operations', () => { expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' }) }) - it('removes a created SSH directory import root when uploadDirectory fails', async () => { - mockDir('/tmp/dropped/assets') - readdirMock.mockResolvedValue([]) - uploadDirMock.mockRejectedValue(new Error('disk full')) + it('removes a created SSH directory import root when nested upload fails', async () => { + const root = path.resolve('/tmp/dropped/assets') + const child = path.join(root, 'logo.png') + lstatMock.mockImplementation(async (p: string) => { + if (p === root) { + return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } + } + if (p === child) { + return { + size: 3, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + } + throw enoent() + }) + readdirMock.mockImplementation(async (p: string) => + p === root + ? [ + { + name: 'logo.png', + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } + ] + : [] + ) + vi.mocked(uploadSession.uploadFile).mockRejectedValue(new Error('disk full')) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/assets'], @@ -237,16 +339,18 @@ describe('fs:importExternalPaths — SSH operations', () => { }) expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) - expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, { - allowExisting: false - }) - expect(removeDirectorySftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`) + expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) + expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true) }) - it('deconflicts directory names via SFTP lstat', async () => { + it('deconflicts directory names via provider stat', async () => { mockDir('/tmp/dropped/assets') - readdirMock.mockResolvedValue([]) - sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/assets`) + vi.mocked(provider.stat).mockImplementation(async (p: string) => { + if (p === `${destDir}/assets`) { + return { type: 'directory', size: 1, mtime: 1 } + } + throw enoent() + }) const { results } = await invoke({ sourcePaths: ['/tmp/dropped/assets'], destDir, @@ -281,7 +385,7 @@ describe('fs:importExternalPaths — SSH operations', () => { connectionId: connId }) expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) - expect(uploadDirMock).not.toHaveBeenCalled() + expect(uploadSession.uploadFile).not.toHaveBeenCalled() }) it('reports skipped when source lstat returns EACCES', async () => { diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index ce07b9122..f834dd904 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -1,8 +1,8 @@ import path from 'node:path' import { constants } from 'node:fs' -import { EventEmitter } from 'node:events' import { Readable, Writable } from 'node:stream' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' const handlers = new Map Promise>() const { @@ -14,11 +14,6 @@ const { openMock, readdirMock, unlinkMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -29,11 +24,6 @@ const { openMock: vi.fn(), readdirMock: vi.fn(), unlinkMock: vi.fn(), - sftpExistsMock: vi.fn(), - uploadFileMock: vi.fn(), - uploadDirMock: vi.fn(), - removeDirectorySftpMock: vi.fn(), - mkdirSftpMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -50,16 +40,13 @@ vi.mock('fs/promises', () => ({ unlink: unlinkMock, rm: vi.fn() })) -vi.mock('../ssh/sftp-upload', () => ({ - sftpPathExists: sftpExistsMock, - uploadFile: uploadFileMock, - uploadDirectory: uploadDirMock, - removeDirectorySftp: removeDirectorySftpMock, - mkdirSftp: mkdirSftpMock -})) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' const store = { getRepos: () => [ @@ -75,39 +62,52 @@ const store = { } const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -type MockSftpWriteStream = EventEmitter & { - destroy: ReturnType - end: ReturnType -} - -function createMockSftpWriteStream(): MockSftpWriteStream { - const stream = new EventEmitter() as MockSftpWriteStream - stream.destroy = vi.fn() - stream.end = vi.fn(() => stream.emit('close')) - return stream +function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { + return { + readDir: vi.fn(), + readFile: vi.fn(), + downloadFile: vi.fn(), + openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileBase64: vi.fn(), + writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockRejectedValue(enoent()), + deletePath: vi.fn().mockResolvedValue(undefined), + createFile: vi.fn(), + createDir: vi.fn().mockResolvedValue(undefined), + createDirNoClobber: vi.fn().mockResolvedValue(undefined), + rename: vi.fn(), + renameNoClobber: vi.fn(), + copy: vi.fn(), + realpath: vi.fn(), + search: vi.fn(), + listFiles: vi.fn(), + watch: vi.fn() + } as unknown as IFilesystemProvider } describe('fs:importExternalPaths — SSH routing & connection', () => { const destDir = '/home/user/project/src' const connId = 'ssh-conn-1' - const sftpWriteStreams: MockSftpWriteStream[] = [] - const mockSftp = { - end: vi.fn(), - createWriteStream: vi.fn(() => { - const stream = createMockSftpWriteStream() - sftpWriteStreams.push(stream) - return stream - }) - } + let provider: IFilesystemProvider + let uploadSession: FileUploadSession + const makeConn = (status = 'connected') => ({ getState: () => ({ status }), - sftp: vi.fn().mockResolvedValue(mockSftp) + sftp: vi.fn() }) const mockFile = (p: string): void => { const rp = path.resolve(p) lstatMock.mockImplementation(async (x: string) => { if (x === rp) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } + return { + size: 12, + ino: 1, + dev: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false + } } throw enoent() }) @@ -128,16 +128,8 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { openMock, readdirMock, unlinkMock, - sftpExistsMock, - uploadFileMock, - uploadDirMock, - removeDirectorySftpMock, - mkdirSftpMock, getConnMgrMock ].forEach((m) => m.mockReset()) - mockSftp.end.mockReset() - mockSftp.createWriteStream.mockClear() - sftpWriteStreams.length = 0 handleMock.mockImplementation((ch: string, h: never) => { handlers.set(ch, h) }) @@ -167,29 +159,36 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { } }) unlinkMock.mockResolvedValue(undefined) - sftpExistsMock.mockResolvedValue(false) - uploadFileMock.mockResolvedValue(undefined) - uploadDirMock.mockResolvedValue(undefined) - removeDirectorySftpMock.mockResolvedValue(undefined) - mkdirSftpMock.mockResolvedValue(undefined) + uploadSession = { + uploadFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn() + } + provider = createProvider(uploadSession) + registerSshFilesystemProvider(connId, provider) registerFilesystemMutationHandlers(store as never) }) - it('routes to SFTP when connectionId is present', async () => { + afterEach(() => { + unregisterSshFilesystemProvider(connId) + }) + + it('routes SSH imports through the filesystem provider when connectionId is present', async () => { getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) mockFile('/tmp/dropped/file.txt') + const { results } = await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId }) + expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' }) - expect(uploadFileMock).toHaveBeenCalledWith( - mockSftp, + expect(uploadSession.uploadFile).toHaveBeenCalledWith( path.resolve('/tmp/dropped/file.txt'), `${destDir}/file.txt`, { exclusive: true } ) + expect(uploadSession.close).toHaveBeenCalledOnce() expect(copyFileMock).not.toHaveBeenCalled() }) @@ -206,7 +205,8 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { ) }) - it('returns empty results without opening SFTP', async () => { + it('returns empty results without opening SFTP or requiring a provider', async () => { + unregisterSshFilesystemProvider(connId) const conn = makeConn() getConnMgrMock.mockReturnValue({ getConnection: () => conn }) const { results } = await invoke({ sourcePaths: [], destDir, connectionId: connId }) @@ -235,36 +235,15 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { ).rejects.toThrow('not active') }) - it('throws when conn.sftp() rejects', async () => { - const conn = makeConn() - conn.sftp.mockRejectedValue(new Error('SFTP subsystem not available')) - getConnMgrMock.mockReturnValue({ getConnection: () => conn }) + it('throws when the SSH filesystem provider is unavailable', async () => { + unregisterSshFilesystemProvider(connId) + getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) await expect( invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('SFTP subsystem') + ).rejects.toThrow('Remote connection dropped') }) - it('closes SFTP channel after success', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId }) - expect(mockSftp.end).toHaveBeenCalledOnce() - }) - - it('closes SFTP channel after upload error', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - uploadFileMock.mockRejectedValue(new Error('disk full')) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) - expect(mockSftp.end).toHaveBeenCalledOnce() - }) - - it('removes staging marker write listeners after remote stream close', async () => { + it('uploads terminal-drop staging marker through provider writes', async () => { getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) mockFile('/tmp/dropped/file.txt') @@ -275,9 +254,11 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { ensureDir: true }) - expect(mockSftp.createWriteStream).toHaveBeenCalledWith('/home/user/project/.orca/.gitignore') - expect(sftpWriteStreams[0]!.listenerCount('close')).toBe(0) - expect(sftpWriteStreams[0]!.listenerCount('error')).toBe(0) - expect(sftpWriteStreams[0]!.destroy).toHaveBeenCalledOnce() + expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca') + expect(provider.writeFile).toHaveBeenCalledWith( + '/home/user/project/.orca/.gitignore', + '*\n!.gitignore\n' + ) + expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca/drops') }) }) diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 51c8aaae5..4b5c4cd14 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,20 +1,13 @@ import { lstat, readdir, realpath } from 'node:fs/promises' import { basename, join, posix, resolve } from 'node:path' -import type { SFTPWrapper } from 'ssh2' import { authorizeExternalPath, isENOENT } from './filesystem-auth' import { getSshConnectionManager } from './ssh' -import { - uploadFile, - uploadDirectory, - mkdirSftp, - sftpPathExists, - removeDirectorySftp -} from '../ssh/sftp-upload' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import type { ImportItemResult } from './filesystem-mutations' -// Why: the SSH import path bypasses SshFilesystemProvider and uses -// SshConnection.sftp() directly because the relay's JSON-RPC fs.writeFile -// is text-only and cannot carry binary data without base64 overhead. +// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so +// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows. export async function importExternalPathsSsh( sourcePaths: string[], destDir: string, @@ -39,25 +32,30 @@ export async function importExternalPathsSsh( throw new Error('SSH connection is not active — please reconnect and try again') } - const sftp = await conn.sftp() + const provider = requireSshFilesystemProvider(connectionId) + if (options?.ensureDir) { + // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist + // before the first upload. .orca/ is reserved as Orca-owned remote state; + // see docs/terminal-drop-ssh.md. + await ensureDropStagingDir(provider, destDir) + } + + const results: ImportItemResult[] = [] + const reservedNames = new Set() + if (!provider.openFileUploadSession) { + throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') + } + const uploadSession = await provider.openFileUploadSession() try { - if (options?.ensureDir) { - // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist - // before the first upload. Upload primitives do not create parent dirs, - // and mkdirSftp is not recursive — so walk the parent chain here on the - // same SFTP session to avoid doubling the handshake cost. Writing the - // .orca/.gitignore marker only when absent prevents clobbering user- - // authored patterns. .orca/ is reserved as Orca-owned remote state; - // see docs/terminal-drop-ssh.md. - await ensureDropStagingDir(sftp, destDir) - } - - const results: ImportItemResult[] = [] - const reservedNames = new Set() - for (const sourcePath of sourcePaths) { - const result = await importOneSourceSsh(sftp, sourcePath, destDir, reservedNames) + const result = await importOneSourceSsh( + provider, + uploadSession, + sourcePath, + destDir, + reservedNames + ) results.push(result) if (result.status === 'imported') { // Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt). @@ -66,15 +64,16 @@ export async function importExternalPathsSsh( reservedNames.add(posix.basename(result.destPath)) } } - - return { results } } finally { - sftp.end() + uploadSession.close() } + + return { results } } async function importOneSourceSsh( - sftp: SFTPWrapper, + provider: IFilesystemProvider, + uploadSession: FileUploadSession, sourcePath: string, destDir: string, reservedNames: Set @@ -126,18 +125,22 @@ async function importOneSourceSsh( let createdDestDir: string | null = null try { - const finalName = await deconflictNameSftp(sftp, destDir, originalName, reservedNames) + const finalName = await deconflictName(provider, destDir, originalName, reservedNames) const destPath = `${destDir}/${finalName}` const renamed = finalName !== originalName if (isDir) { - await mkdirSftp(sftp, destPath, { allowExisting: false }) + await provider.createDirNoClobber(destPath) createdDestDir = destPath - await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), { - exclusive: true - }) + await uploadDirectoryViaProvider( + provider, + uploadSession, + resolvedSource, + destPath, + await realpath(resolvedSource) + ) } else { - await uploadFile(sftp, resolvedSource, destPath, { exclusive: true }) + await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true }) } return { @@ -151,7 +154,7 @@ async function importOneSourceSsh( if (createdDestDir) { // Why: local directory imports roll back partial output; SSH imports // should not leave the no-clobber root after a nested upload failure. - await removeDirectorySftp(sftp, createdDestDir).catch(() => {}) + await provider.deletePath(createdDestDir, true).catch(() => {}) } return { sourcePath, @@ -161,14 +164,14 @@ async function importOneSourceSsh( } } -async function deconflictNameSftp( - sftp: SFTPWrapper, +async function deconflictName( + provider: IFilesystemProvider, destDir: string, originalName: string, reservedNames: Set ): Promise { if ( - !(await sftpPathExists(sftp, `${destDir}/${originalName}`)) && + !(await remotePathExists(provider, `${destDir}/${originalName}`)) && !reservedNames.has(originalName) ) { return originalName @@ -180,14 +183,20 @@ async function deconflictNameSftp( const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : '' let candidate = `${stem} copy${ext}` - if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) { + if ( + !(await remotePathExists(provider, `${destDir}/${candidate}`)) && + !reservedNames.has(candidate) + ) { return candidate } let counter = 2 while (counter < 10000) { candidate = `${stem} copy ${counter}${ext}` - if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) { + if ( + !(await remotePathExists(provider, `${destDir}/${candidate}`)) && + !reservedNames.has(candidate) + ) { return candidate } counter += 1 @@ -198,44 +207,72 @@ async function deconflictNameSftp( ) } -async function ensureDropStagingDir(sftp: SFTPWrapper, destDir: string): Promise { - // destDir is a posix remote path, expected to be `${worktreePath}/.orca/drops`. +async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: string): Promise { const parent = posix.dirname(destDir) - await mkdirSftp(sftp, parent) + await provider.createDir(parent) const gitignorePath = `${parent}/.gitignore` - if (!(await sftpPathExists(sftp, gitignorePath))) { - // Why: negate the marker so .orca/.gitignore itself is trackable if we - // ever want to, without dirtying `git status` today. Only write when - // absent to avoid clobbering user-authored patterns. - await writeSftpFile(sftp, gitignorePath, '*\n!.gitignore\n') + if (!(await remotePathExists(provider, gitignorePath))) { + await provider.writeFile(gitignorePath, '*\n!.gitignore\n') } - await mkdirSftp(sftp, destDir) + await provider.createDir(destDir) } -function writeSftpFile(sftp: SFTPWrapper, remotePath: string, contents: string): Promise { - return new Promise((resolve, reject) => { - let settled = false - const writeStream = sftp.createWriteStream(remotePath) - const cleanup = (): void => { - writeStream.off('close', onClose) - writeStream.off('error', onError) - } - const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { - if (settled) { - return - } - settled = true - cleanup() - writeStream.destroy() - fn(val as never) - } - const onClose = (): void => settle(resolve) - const onError = (err: Error): void => settle(reject, err) +async function uploadDirectoryViaProvider( + provider: IFilesystemProvider, + uploadSession: FileUploadSession, + localDir: string, + remoteDir: string, + rootRealPath: string +): Promise { + await assertLocalUploadPathInsideRoot(rootRealPath, localDir) + const entries = await readdir(localDir, { withFileTypes: true }) + for (const entry of entries) { + const localPath = join(localDir, entry.name) + const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) - writeStream.on('close', onClose) - writeStream.on('error', onError) - writeStream.end(contents) - }) + // Why: skip symlinks and special files even after the up-front pre-scan; + // this closes the TOCTOU gap if one is created during upload. + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue + } + + if (statResult.isDirectory()) { + await provider.createDirNoClobber(remotePath) + await uploadDirectoryViaProvider(provider, uploadSession, localPath, remotePath, rootRealPath) + continue + } + await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) + } +} + +async function remotePathExists( + provider: IFilesystemProvider, + remotePath: string +): Promise { + try { + await provider.stat(remotePath) + return true + } catch (error) { + if (isRemoteMissingError(error)) { + return false + } + throw error + } +} + +function isRemoteMissingError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = (error as NodeJS.ErrnoException).code + return ( + code === 'ENOENT' || + /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( + error.message + ) + ) } async function preScanForSymlinks(dirPath: string): Promise { @@ -253,3 +290,21 @@ async function preScanForSymlinks(dirPath: string): Promise { } return false } + +async function assertLocalUploadPathInsideRoot( + rootRealPath: string, + candidatePath: string +): Promise { + const candidateRealPath = await realpath(candidatePath) + const root = rootRealPath.replace(/[\\/]+$/g, '') + const candidate = candidateRealPath.replace(/[\\/]+$/g, '') + const rootComparable = process.platform === 'win32' ? root.toLowerCase() : root + const candidateComparable = process.platform === 'win32' ? candidate.toLowerCase() : candidate + if (candidateComparable === rootComparable) { + return + } + const boundary = process.platform === 'win32' ? '\\' : '/' + if (!candidateComparable.startsWith(`${rootComparable}${boundary}`)) { + throw new Error(`Upload source escapes selected directory: ${candidatePath}`) + } +} diff --git a/src/main/providers/ssh-filesystem-file-upload.ts b/src/main/providers/ssh-filesystem-file-upload.ts new file mode 100644 index 000000000..6fa83145d --- /dev/null +++ b/src/main/providers/ssh-filesystem-file-upload.ts @@ -0,0 +1,35 @@ +import type { SFTPWrapper } from 'ssh2' +import { uploadFile as uploadFileViaSftp } from '../ssh/sftp-upload' +import type { FileUploadSession } from './types' + +export type SftpFactory = () => Promise + +export type SshRawTransferOptions = { + downloadFile?: (sourcePath: string, destinationPath: string) => Promise + openFileUploadSession?: () => Promise + writeBuffer?: ( + remotePath: string, + contents: Buffer, + options: { append: boolean; exclusive: boolean } + ) => Promise +} + +export async function openSshFileUploadSession( + createSftp?: SftpFactory, + rawTransfer?: SshRawTransferOptions +): Promise { + if (rawTransfer?.openFileUploadSession) { + return rawTransfer.openFileUploadSession() + } + if (!createSftp) { + throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') + } + const sftp = await createSftp() + return { + // Why: one session covers the whole import so normal SSH keeps its prior + // channel count even when a directory contains many files. + uploadFile: (sourcePath, destinationPath, options) => + uploadFileViaSftp(sftp, sourcePath, destinationPath, options), + close: () => sftp.end() + } +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index d35460bd4..cc3e890c3 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' import { SshFilesystemProvider } from './ssh-filesystem-provider' import { JsonRpcErrorCode } from '../ssh/relay-protocol' @@ -201,6 +205,23 @@ describe('SshFilesystemProvider', () => { expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything()) }) + it('writes decoded bytes through raw transfer when provided', async () => { + const writeBuffer = vi.fn().mockResolvedValue(undefined) + provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { writeBuffer }) + + await provider.writeFileBase64('/home/user/logo.png', 'cG5n') + await provider.writeFileBase64Chunk('/home/user/logo.png', 'bW9yZQ==', true) + + expect(writeBuffer).toHaveBeenNthCalledWith(1, '/home/user/logo.png', Buffer.from('png'), { + append: false, + exclusive: true + }) + expect(writeBuffer).toHaveBeenNthCalledWith(2, '/home/user/logo.png', Buffer.from('more'), { + append: true, + exclusive: false + }) + }) + it('can append decoded chunks through SFTP', async () => { const writeStream = { on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream), @@ -225,6 +246,15 @@ describe('SshFilesystemProvider', () => { }) describe('downloadFile', () => { + it('downloads raw bytes through raw transfer when provided', async () => { + const downloadFile = vi.fn().mockResolvedValue(undefined) + provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { downloadFile }) + + await provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip') + + expect(downloadFile).toHaveBeenCalledWith('/home/user/archive.zip', '/tmp/archive.zip') + }) + it('downloads raw bytes through SFTP and closes the session', async () => { const sftp = { fastGet: vi.fn( @@ -263,6 +293,48 @@ describe('SshFilesystemProvider', () => { }) }) + describe('openFileUploadSession', () => { + it('uses one SFTP session for multiple files and closes it once', async () => { + const createWriteStream = vi.fn(() => { + const stream = new PassThrough() + stream.resume() + return stream + }) + const sftp = { createWriteStream, end: vi.fn() } + const createSftp = vi.fn().mockResolvedValue(sftp) + provider = new SshFilesystemProvider('conn-1', mux as never, createSftp as never) + const dir = mkdtempSync(join(tmpdir(), 'orca-sftp-upload-session-')) + const first = join(dir, 'first.txt') + const second = join(dir, 'second.txt') + writeFileSync(first, 'first') + writeFileSync(second, 'second') + + try { + const session = await provider.openFileUploadSession() + await session.uploadFile(first, '/remote/first.txt', { exclusive: true }) + await session.uploadFile(second, '/remote/second.txt', { exclusive: true }) + session.close() + + expect(createSftp).toHaveBeenCalledOnce() + expect(createWriteStream).toHaveBeenCalledTimes(2) + expect(sftp.end).toHaveBeenCalledOnce() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('uses the transport-owned upload session when provided', async () => { + const uploadSession = { uploadFile: vi.fn(), close: vi.fn() } + const openFileUploadSession = vi.fn().mockResolvedValue(uploadSession) + provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { + openFileUploadSession + }) + + await expect(provider.openFileUploadSession()).resolves.toBe(uploadSession) + expect(openFileUploadSession).toHaveBeenCalledOnce() + }) + }) + describe('createDirNoClobber', () => { it('sends fs.createDirNoClobber request', async () => { await provider.createDirNoClobber('/home/user/new-dir') diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index ce5256d27..cb95683f9 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -2,6 +2,11 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import { uploadBuffer } from '../ssh/sftp-upload' import { fastGetViaSftp, lstatViaSftp } from './ssh-filesystem-provider-sftp' +import { + openSshFileUploadSession, + type SftpFactory, + type SshRawTransferOptions +} from './ssh-filesystem-file-upload' import { notifySshFilesystemUnwatch, registerSshFilesystemWatch, @@ -11,14 +16,12 @@ import type { IFilesystemProvider, FileStat, FileReadResult, + FileUploadSession, TerminalArtifactAccessOptions } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import type { SFTPWrapper } from 'ssh2' - -type SftpFactory = () => Promise const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 export class SshFilesystemProvider implements IFilesystemProvider { @@ -33,7 +36,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { constructor( connectionId: string, mux: SshChannelMultiplexer, - private readonly createSftp?: SftpFactory + private readonly createSftp?: SftpFactory, + private readonly rawTransfer?: SshRawTransferOptions ) { this.connectionId = connectionId this.mux = mux @@ -120,6 +124,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async downloadFile(sourcePath: string, destinationPath: string): Promise { + if (this.rawTransfer?.downloadFile) { + await this.rawTransfer.downloadFile(sourcePath, destinationPath) + return + } if (!this.createSftp) { throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') } @@ -131,6 +139,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } + async openFileUploadSession(): Promise { + return openSshFileUploadSession(this.createSftp, this.rawTransfer) + } + async getTempDir(): Promise { this.tempDirPromise ??= this.mux.request('fs.tempDir', {}).then( (result) => result as string, @@ -186,6 +198,11 @@ export class SshFilesystemProvider implements IFilesystemProvider { contentBase64: string, append: boolean ): Promise { + const contents = Buffer.from(contentBase64, 'base64') + if (this.rawTransfer?.writeBuffer) { + await this.rawTransfer.writeBuffer(filePath, contents, { append, exclusive: !append }) + return + } if (!this.createSftp) { throw new Error('remote_binary_upload_unavailable') } @@ -193,7 +210,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { try { // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes // directly so runtime uploads do not corrupt images, PDFs, or archives. - await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, { + await uploadBuffer(sftp, contents, filePath, { append, exclusive: !append }) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index d5873c948..d5cf5c609 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -250,6 +250,7 @@ export type IFilesystemProvider = { options: TerminalArtifactAccessOptions ): Promise downloadFile?(sourcePath: string, destinationPath: string): Promise + openFileUploadSession?(): Promise getTempDir?(): Promise writeFile(filePath: string, content: string): Promise writeTerminalArtifact?( @@ -281,6 +282,15 @@ export type IFilesystemProvider = { watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> } +export type FileUploadSession = { + uploadFile( + sourcePath: string, + destinationPath: string, + options?: { exclusive?: boolean } + ): Promise + close(): void +} + export type TerminalArtifactAccessOptions = { expectedRealPath: string expectedStatIdentity: string | null diff --git a/src/main/runtime/orca-runtime-terminal-cwd.test.ts b/src/main/runtime/orca-runtime-terminal-cwd.test.ts index 976b8169f..f8868848e 100644 --- a/src/main/runtime/orca-runtime-terminal-cwd.test.ts +++ b/src/main/runtime/orca-runtime-terminal-cwd.test.ts @@ -109,6 +109,9 @@ describe('OrcaRuntimeService terminal startup cwd', () => { it('materializes restored headless mobile tabs in the persisted startup cwd', async () => { const store = { + // wt-1 is a worktree id, not a registered repo, so getRepo returns null; + // the selector validator calls it to reject repo ids passed as worktree ids. + getRepo: () => null, getWorkspaceSession: () => ({ activeRepoId: null, activeWorktreeId: 'wt-1', diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 28bc9714d..005d43c9c 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -133,7 +133,10 @@ vi.mock('./ssh-system-fallback', () => ({ getOrcaControlSocketPath: getOrcaControlSocketPathMock, spawnSystemSsh: spawnSystemSshMock, spawnSystemSshCommand: spawnSystemSshCommandMock, + downloadFileViaSystemSsh: vi.fn(), uploadDirectoryViaSystemSsh: vi.fn(), + uploadFileViaSystemSsh: vi.fn(), + writeBufferViaSystemSsh: vi.fn(), writeFileViaSystemSsh: vi.fn() })) @@ -152,7 +155,13 @@ import { type SshConnectionCallbacks } from './ssh-connection' import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' -import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' +import { + downloadFileViaSystemSsh, + uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, + writeFileViaSystemSsh +} from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' @@ -273,8 +282,14 @@ describe('SshConnection', () => { spawnSystemSshMock.mockImplementation(() => createSystemSshProcess()) spawnSystemSshCommandMock.mockReset() spawnSystemSshCommandMock.mockImplementation(() => createSystemCommandChannel()) + vi.mocked(downloadFileViaSystemSsh).mockReset() + vi.mocked(downloadFileViaSystemSsh).mockResolvedValue(undefined) vi.mocked(uploadDirectoryViaSystemSsh).mockReset() vi.mocked(uploadDirectoryViaSystemSsh).mockResolvedValue(undefined) + vi.mocked(uploadFileViaSystemSsh).mockReset() + vi.mocked(uploadFileViaSystemSsh).mockResolvedValue(undefined) + vi.mocked(writeBufferViaSystemSsh).mockReset() + vi.mocked(writeBufferViaSystemSsh).mockResolvedValue(undefined) vi.mocked(writeFileViaSystemSsh).mockReset() vi.mocked(writeFileViaSystemSsh).mockResolvedValue(undefined) vi.mocked(resolveWithSshG).mockReset() @@ -1057,6 +1072,18 @@ describe('SshConnection', () => { await conn.writeFile('C:/Users/me/.orca-remote/relay/.version', '0.1.0', { hostPlatform }) + await conn.writeBuffer('C:/Users/me/.orca-remote/relay/logo.png', Buffer.from('png'), { + hostPlatform, + exclusive: true + }) + await conn.downloadFile('C:/Users/me/.orca-remote/relay/logo.png', '/tmp/logo.png', { + hostPlatform + }) + const uploadSession = await conn.openFileUploadSession({ hostPlatform }) + await uploadSession.uploadFile('/tmp/logo.png', 'C:/Users/me/project/logo.png', { + exclusive: true + }) + uploadSession.close() expect(uploadDirectoryViaSystemSsh).toHaveBeenCalledWith( expect.objectContaining({ configHost: 'fdpass-host' }), @@ -1076,6 +1103,65 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) }) ) + expect(writeBufferViaSystemSsh).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + 'C:/Users/me/.orca-remote/relay/logo.png', + Buffer.from('png'), + expect.objectContaining({ + hostPlatform, + exclusive: true, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + expect(downloadFileViaSystemSsh).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + 'C:/Users/me/.orca-remote/relay/logo.png', + '/tmp/logo.png', + expect.objectContaining({ + hostPlatform, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + expect(uploadFileViaSystemSsh).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'fdpass-host' }), + '/tmp/logo.png', + 'C:/Users/me/project/logo.png', + expect.objectContaining({ + hostPlatform, + exclusive: true, + resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) + }) + ) + }) + + it('keeps an upload session cancelled after the connection disconnects', async () => { + const conn = new SshConnection( + createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }), + createCallbacks() + ) + vi.mocked(uploadFileViaSystemSsh).mockImplementation( + async (_target, _localPath, _remotePath, options) => { + if (options?.signal?.aborted) { + const error = new Error('System SSH operation was cancelled') + error.name = 'AbortError' + throw error + } + } + ) + + await conn.connect() + const uploadSession = await conn.openFileUploadSession() + await conn.disconnect() + + await expect( + uploadSession.uploadFile('/tmp/late.txt', '/remote/late.txt') + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(uploadFileViaSystemSsh).toHaveBeenCalledWith( + expect.anything(), + '/tmp/late.txt', + '/remote/late.txt', + expect.objectContaining({ signal: expect.objectContaining({ aborted: true }) }) + ) }) it('removes system SSH probe listeners after timeout', async () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index eb2e54909..af74bdc71 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -8,7 +8,10 @@ import { getOrcaControlSocketPath, spawnSystemSsh, spawnSystemSshCommand, + downloadFileViaSystemSsh, uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, writeFileViaSystemSsh, type SystemSshBuildArgsOptions, type SystemSshProcess @@ -35,6 +38,7 @@ import { type SshConnectionCallbacks } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' +import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' export type { SshConnectionCallbacks } from './ssh-connection-utils' @@ -347,6 +351,54 @@ export class SshConnection { }) } + async downloadFile( + remotePath: string, + localPath: string, + options?: SshRemoteFileOptions + ): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') + await fastGetViaSftp(sftp, remotePath, localPath) + } finally { + sftp.end() + } + return + } + await downloadFileViaSystemSsh(this.target, remotePath, localPath, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } + + async openFileUploadSession(options?: SshRemoteFileOptions): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + const { uploadFile } = await import('./sftp-upload') + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFile(sftp, localPath, remotePath, uploadOptions), + close: () => sftp.end() + } + } + // Why: disconnect replaces the connection controller; an existing import + // session must stay bound to the signal and SSH config it opened with. + const signal = this.systemOperationAbortController.signal + const buildArgsOptions = this.getSystemSshBuildArgsOptions() + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFileViaSystemSsh(this.target, localPath, remotePath, { + signal, + hostPlatform: options?.hostPlatform, + exclusive: uploadOptions?.exclusive, + ...buildArgsOptions + }), + close: () => {} + } + } + async writeFile( remotePath: string, contents: string, @@ -401,6 +453,30 @@ export class SshConnection { }) } + async writeBuffer( + remotePath: string, + contents: Buffer, + options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } + ): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { uploadBuffer } = await import('./sftp-upload') + await uploadBuffer(sftp, contents, remotePath, options) + } finally { + sftp.end() + } + return + } + await writeBufferViaSystemSsh(this.target, remotePath, contents, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + append: options?.append, + exclusive: options?.exclusive, + ...this.getSystemSshBuildArgsOptions() + }) + } + async connect(): Promise { if (this.disposed) { throw new Error('Connection disposed') diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 5176d0641..05b0068ca 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -674,8 +674,26 @@ export class SshRelaySession { const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined) registerSshPtyProvider(this.targetId, ptyProvider) - const fsProvider = new SshFilesystemProvider(this.targetId, mux, () => - this.requireReadyConnection().sftp() + const fsProvider = new SshFilesystemProvider( + this.targetId, + mux, + () => this.requireReadyConnection().sftp(), + { + downloadFile: (sourcePath, destinationPath) => + this.requireReadyConnection().downloadFile(sourcePath, destinationPath, { + hostPlatform: this.remoteCliBridgeEnv?.hostPlatform + }), + openFileUploadSession: () => + this.requireReadyConnection().openFileUploadSession({ + hostPlatform: this.remoteCliBridgeEnv?.hostPlatform + }), + writeBuffer: (remotePath, contents, options) => + this.requireReadyConnection().writeBuffer(remotePath, contents, { + hostPlatform: this.remoteCliBridgeEnv?.hostPlatform, + append: options.append, + exclusive: options.exclusive + }) + } ) registerSshFilesystemProvider(this.targetId, fsProvider) diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 5bec0bb00..324a0ed6e 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { EventEmitter } from 'node:events' @@ -25,9 +25,12 @@ vi.mock('child_process', () => ({ import { buildSshArgs, findSystemSsh, + downloadFileViaSystemSsh, spawnSystemSsh, spawnSystemSshCommand, uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' import { spawnSystemSshPortForward } from './system-ssh-forward-process' @@ -38,6 +41,11 @@ import type { SystemSshResolvedConfig } from './ssh-control-socket' const SYSTEM_SSH_PATH = process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+(\S+)/)?.[1] + return encoded ? Buffer.from(encoded, 'base64').toString('utf16le') : command +} + function mockSystemSshExists(): void { existsSyncMock.mockImplementation((p: string) => p === SYSTEM_SSH_PATH) } @@ -462,6 +470,23 @@ describe('spawnSystemSsh', () => { expect(proc.listenerCount('error')).toBe(0) }) + it('pauses command stdout under backpressure and resumes when the channel reads', async () => { + const proc = createMockChildProcess() + const pause = vi.spyOn(proc.stdout, 'pause') + const resume = vi.spyOn(proc.stdout, 'resume') + spawnMock.mockReturnValue(proc) + + const channel = spawnSystemSshCommand(createTarget(), 'cat /tmp/large-file') + resume.mockClear() + proc.stdout.write(Buffer.alloc(128 * 1024)) + + expect(pause).toHaveBeenCalled() + resume.mockClear() + channel.read() + await new Promise((resolve) => setImmediate(resolve)) + expect(resume).toHaveBeenCalled() + }) + it('removes write command wait listeners after close', async () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) @@ -470,10 +495,90 @@ describe('spawnSystemSsh', () => { proc.emit('close', 0, null) await expect(promise).resolves.toBeUndefined() - expect(proc.stdin.end).toHaveBeenCalledWith('contents') + expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('contents')) expect(proc.stderr.listenerCount('data')).toBe(0) }) + it('writes binary buffers to POSIX system SSH targets with exclusive create', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + + const promise = writeBufferViaSystemSsh(createTarget(), '/tmp/file', Buffer.from('png'), { + exclusive: true + }) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('set -C; cat >') + expect(args.at(-1)).toContain('/tmp/file') + expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('png')) + }) + + it('streams a local file through one POSIX system SSH command', async () => { + const proc = createMockChildProcess() + const received: Buffer[] = [] + proc.stdin.on('data', (chunk: Buffer) => received.push(chunk)) + spawnMock.mockReturnValue(proc) + const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-')) + const source = join(dir, 'payload.bin') + writeFileSync(source, Buffer.from('payload')) + + try { + const promise = uploadFileViaSystemSsh(createTarget(), source, '/remote/payload.bin', { + exclusive: true + }) + await new Promise((resolve) => proc.stdin.once('finish', resolve)) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + expect(Buffer.concat(received)).toEqual(Buffer.from('payload')) + expect(spawnMock).toHaveBeenCalledTimes(1) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('set -C; cat >') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('appends binary buffers to POSIX system SSH targets', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + + const promise = writeBufferViaSystemSsh(createTarget(), '/tmp/file', Buffer.from('more'), { + append: true + }) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('cat >>') + expect(args.at(-1)).toContain('/tmp/file') + expect(args.at(-1)).not.toContain('set -C') + }) + + it('downloads files from POSIX system SSH targets', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-download-')) + const dest = join(dir, 'payload.bin') + + try { + const promise = downloadFileViaSystemSsh(createTarget(), '/remote/payload.bin', dest) + proc.stdout.emit('data', Buffer.from('payload')) + proc.stdout.emit('end') + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + expect(readFileSync(dest)).toEqual(Buffer.from('payload')) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args.at(-1)).toContain('cat') + expect(args.at(-1)).toContain('/remote/payload.bin') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('forces standalone SSH for POSIX file writes when requested', async () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) @@ -511,6 +616,56 @@ describe('spawnSystemSsh', () => { expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('0.1.0', 'utf-8')) }) + it('writes binary buffers to Windows system SSH targets with CreateNew mode', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + const hostPlatform = getRemoteHostPlatform('win32-x64') + + const promise = writeBufferViaSystemSsh( + createTarget(), + 'C:/Users/me/logo.png', + Buffer.from('png'), + { hostPlatform, exclusive: true } + ) + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + const args = spawnMock.mock.calls[0][1] as string[] + const remoteCommand = args.at(-1) ?? '' + expect(remoteCommand).toContain('powershell.exe') + expect(decodePowerShellCommand(remoteCommand)).toContain('CreateNew') + expect(remoteCommand).not.toContain('/bin/sh') + expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('png')) + }) + + it('downloads files from Windows system SSH targets with PowerShell stdout bytes', async () => { + const proc = createEventedProcess() + spawnMock.mockReturnValue(proc) + const hostPlatform = getRemoteHostPlatform('win32-x64') + const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-download-')) + const dest = join(dir, 'payload.bin') + + try { + const promise = downloadFileViaSystemSsh(createTarget(), 'C:/Users/me/payload.bin', dest, { + hostPlatform + }) + proc.stdout.emit('data', Buffer.from('payload')) + proc.stdout.emit('end') + proc.emit('close', 0, null) + + await expect(promise).resolves.toBeUndefined() + expect(readFileSync(dest)).toEqual(Buffer.from('payload')) + const args = spawnMock.mock.calls[0][1] as string[] + const remoteCommand = args.at(-1) ?? '' + expect(remoteCommand).toContain('powershell.exe') + expect(decodePowerShellCommand(remoteCommand)).toContain('OpenRead') + expect(decodePowerShellCommand(remoteCommand)).toContain('CopyTo') + expect(remoteCommand).not.toContain('/bin/sh') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('forces standalone SSH for Windows file writes when requested', async () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/ssh/ssh-system-fallback.ts b/src/main/ssh/ssh-system-fallback.ts index 01c3d978c..b34a7579a 100644 --- a/src/main/ssh/ssh-system-fallback.ts +++ b/src/main/ssh/ssh-system-fallback.ts @@ -5,4 +5,9 @@ export { type SystemSshBuildArgsOptions } from './system-ssh-args' export { spawnSystemSsh, spawnSystemSshCommand, type SystemSshProcess } from './system-ssh-command' +export { + downloadFileViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh +} from './system-ssh-file-binary-transfer' export { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './system-ssh-file-transfer' diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index e3051201a..1f0ce5ed6 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -90,7 +90,9 @@ function wrapChildProcess(proc: ChildProcess): SystemSshProcess { function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { const duplex = new Duplex({ - read() {}, + read() { + proc.stdout?.resume() + }, write(chunk, encoding, cb) { proc.stdin!.write(chunk, encoding, cb) } @@ -128,7 +130,11 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { duplex.destroy(err) } const onStdoutData = (data: Buffer): void => { - duplex.push(data) + // Why: file downloads can outpace the local destination; pause OpenSSH + // instead of buffering the producer-consumer lag in the main process. + if (!duplex.push(data)) { + proc.stdout!.pause() + } } const onStdoutEnd = (): void => { duplex.push(null) diff --git a/src/main/ssh/system-ssh-file-binary-transfer.ts b/src/main/ssh/system-ssh-file-binary-transfer.ts new file mode 100644 index 000000000..e86aa3c9a --- /dev/null +++ b/src/main/ssh/system-ssh-file-binary-transfer.ts @@ -0,0 +1,215 @@ +import { constants, createWriteStream } from 'node:fs' +import { lstat, open } from 'node:fs/promises' +import type { Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import type { SshTarget } from '../../shared/ssh-types' +import { shellEscape } from './ssh-connection-utils' +import { + getSystemSshBuildArgsFromOperationOptions, + type SystemSshBuildArgsOptions +} from './system-ssh-args' +import { spawnSystemSshCommand } from './system-ssh-command' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + awaitWithSystemSshAbort, + throwIfAborted, + waitForChannelClose +} from './system-ssh-operation-lifecycle' + +type SystemSshOperationOptions = SystemSshBuildArgsOptions & { + signal?: AbortSignal + hostPlatform?: RemoteHostPlatform +} + +type SystemSshWriteBufferOptions = SystemSshOperationOptions & { + append?: boolean + exclusive?: boolean +} + +type SystemSshUploadFileOptions = SystemSshOperationOptions & { + exclusive?: boolean +} + +export async function downloadFileViaSystemSsh( + target: SshTarget, + remotePath: string, + localPath: string, + options?: SystemSshOperationOptions +): Promise { + throwIfAborted(options?.signal) + const isWindows = options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform) + const command = isWindows + ? makeWindowsReadFileCommand(remotePath) + : `cat ${shellEscape(remotePath)}` + const channel = spawnSystemSshCommand(target, command, { + wrapCommand: !isWindows, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const output = createWriteStream(localPath, { flags: 'wx' }) + try { + await awaitWithSystemSshAbort( + options?.signal, + () => { + channel.close() + output.destroy() + }, + Promise.all([ + waitForChannelClose(channel, `download ${remotePath}`), + pipeline(channel, output) + ]) + ) + } catch (error) { + channel.close() + output.destroy() + throw error + } +} + +export async function writeBufferViaSystemSsh( + target: SshTarget, + remotePath: string, + contents: Buffer, + options?: SystemSshWriteBufferOptions +): Promise { + throwIfAborted(options?.signal) + if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { + await writeBufferViaSystemSshWindows(target, remotePath, contents, options) + return + } + + const channel = spawnSystemSshCommand( + target, + makePosixWriteFileCommand(remotePath, options), + getSystemSshBuildArgsFromOperationOptions(options) + ) + const closePromise = awaitWithSystemSshAbort( + options?.signal, + () => channel.close(), + waitForChannelClose(channel, `write ${remotePath}`) + ) + if (!options?.signal?.aborted) { + channel.stdin.end(contents) + } + await closePromise +} + +export async function uploadFileViaSystemSsh( + target: SshTarget, + localPath: string, + remotePath: string, + options?: SystemSshUploadFileOptions +): Promise { + throwIfAborted(options?.signal) + const sourceStat = await lstat(localPath) + if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) { + throw new Error(`Unsupported upload source: ${localPath}`) + } + + const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await handle.stat() + if ( + !openedStat.isFile() || + openedStat.size !== sourceStat.size || + (sourceStat.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== sourceStat.ino) || + (sourceStat.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== sourceStat.dev) + ) { + throw new Error(`File changed during upload: ${localPath}`) + } + throwIfAborted(options?.signal) + + const isWindows = options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform) + const channel = spawnSystemSshCommand( + target, + isWindows + ? makeWindowsWriteFileCommand(remotePath, options) + : makePosixWriteFileCommand(remotePath, options), + { + wrapCommand: !isWindows, + ...getSystemSshBuildArgsFromOperationOptions(options) + } + ) + const input = handle.createReadStream({ autoClose: false }) + try { + await awaitWithSystemSshAbort( + options?.signal, + () => { + input.destroy() + channel.close() + }, + Promise.all([ + waitForChannelClose(channel, `upload ${remotePath}`), + pipeline(input, channel.stdin as Writable) + ]) + ) + } catch (error) { + input.destroy() + channel.close() + throw error + } + } finally { + await handle.close() + } +} + +async function writeBufferViaSystemSshWindows( + target: SshTarget, + remotePath: string, + contents: Buffer, + options: SystemSshWriteBufferOptions +): Promise { + throwIfAborted(options.signal) + const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath, options), { + wrapCommand: false, + ...getSystemSshBuildArgsFromOperationOptions(options) + }) + const closePromise = awaitWithSystemSshAbort( + options.signal, + () => channel.close(), + waitForChannelClose(channel, `write ${remotePath}`) + ) + if (!options.signal?.aborted) { + channel.stdin.end(contents) + } + await closePromise +} + +function makeWindowsWriteFileCommand( + remotePath: string, + options?: { append?: boolean; exclusive?: boolean } +): string { + const fileMode = options?.append ? 'Append' : options?.exclusive ? 'CreateNew' : 'Create' + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + `$path = ${powerShellLiteral(remotePath)}`, + '$parent = [System.IO.Path]::GetDirectoryName($path)', + 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', + '$inputStream = [Console]::OpenStandardInput()', + `$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::${fileMode}, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)`, + 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' + ].join('; ') + ) +} + +function makePosixWriteFileCommand( + remotePath: string, + options?: { append?: boolean; exclusive?: boolean } +): string { + const redirection = options?.append ? '>>' : '>' + const noclobber = !options?.append && options?.exclusive ? 'set -C; ' : '' + return `${noclobber}cat ${redirection} ${shellEscape(remotePath)}` +} + +function makeWindowsReadFileCommand(remotePath: string): string { + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + `$path = ${powerShellLiteral(remotePath)}`, + '$src = [System.IO.File]::OpenRead($path)', + '$dst = [Console]::OpenStandardOutput()', + 'try { $src.CopyTo($dst) } finally { $src.Dispose() }' + ].join('; ') + ) +} diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index 345ab6909..72cb4425d 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -13,7 +13,7 @@ import { } from './system-ssh-args' import { spawnSystemSshCommand } from './system-ssh-command' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { powerShellCommand } from './ssh-remote-powershell' import { awaitWithSystemSshAbort, killProcess, @@ -22,6 +22,7 @@ import { waitForProcess, type ProcessResult } from './system-ssh-operation-lifecycle' +import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' type SystemSshOperationOptions = SystemSshBuildArgsOptions & { signal?: AbortSignal @@ -95,30 +96,7 @@ export async function writeFileViaSystemSsh( options?: SystemSshOperationOptions ): Promise { throwIfAborted(options?.signal) - if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) { - await writeBufferViaSystemSshWindows( - target, - remotePath, - Buffer.from(contents, 'utf-8'), - options - ) - return - } - - const channel = spawnSystemSshCommand( - target, - `cat > ${shellEscape(remotePath)}`, - getSystemSshBuildArgsFromOperationOptions(options) - ) - const closePromise = awaitWithSystemSshAbort( - options?.signal, - () => channel.close(), - waitForChannelClose(channel, `write ${remotePath}`) - ) - if (!options?.signal?.aborted) { - channel.stdin.end(contents) - } - await closePromise + await writeBufferViaSystemSsh(target, remotePath, Buffer.from(contents, 'utf-8'), options) } async function uploadDirectoryViaSystemSshWindows( @@ -221,42 +199,6 @@ async function readLocalUploadFile( } } -async function writeBufferViaSystemSshWindows( - target: SshTarget, - remotePath: string, - contents: Buffer, - options: SystemSshOperationOptions -): Promise { - throwIfAborted(options.signal) - const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath), { - wrapCommand: false, - ...getSystemSshBuildArgsFromOperationOptions(options) - }) - const closePromise = awaitWithSystemSshAbort( - options.signal, - () => channel.close(), - waitForChannelClose(channel, `write ${remotePath}`) - ) - if (!options.signal?.aborted) { - channel.stdin.end(contents) - } - await closePromise -} - -function makeWindowsWriteFileCommand(remotePath: string): string { - return powerShellCommand( - [ - '$ErrorActionPreference = "Stop"', - `$path = ${powerShellLiteral(remotePath)}`, - '$parent = [System.IO.Path]::GetDirectoryName($path)', - 'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }', - '$inputStream = [Console]::OpenStandardInput()', - '$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', - 'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }' - ].join('; ') - ) -} - function makeWindowsUploadPackageCommand(): string { return powerShellCommand( [