Files
orca/src/main/ipc/filesystem-import-ssh.ts
T
Neil 15e1ba3f84 refactor(ipc): split main-process IPC modules under the max-lines budget (#14703)
The six oversized src/main/ipc modules each carried a file-level
`eslint-disable max-lines` and ran 427-671 counted lines against a 300-line
budget. AGENTS.md calls for splitting rather than suppressing, and
config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all six
suppressions and prunes their entries (341 -> 335).

Pure move, no behavior change. Each file is cut along the seams it already had:
pet splits into format allowlist / storage paths / symlink-safe copy / bundle
manifest + import; filesystem-auth into path-containment primitives, the
config-derived allow-list, and the git-registered root cache; notifications into
sound selection, native lifecycle, permission probe, and burst cooldown;
crash-reporting into renderer error reports, breadcrumbs, and sender.

The IPC surface is proved intact rather than assumed: comparing (method,
channel) multisets between HEAD and the split gives 49 registrations across 49
distinct channels on both sides. filesystem-auth's security boundary keeps its
acyclic layering -- containment primitives, then allow-list, then root cache,
then path-resolution orchestration -- with no layer gaining a back-edge.

Also keeps clipboard-ipc-handlers.test.ts under the 800-line test budget. The
split had briefly added a redundant vi.mock for isENOENT (byte-identical to the
real implementation) that pushed it to 801; the mock is dropped in favor of the
real function, with realpath added to the existing node:fs/promises mock.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(the one remaining failure is a pre-existing load flake in an untouched file,
green when re-run serially), no new runtime import cycles among 617 modules,
and no lint suppression added anywhere.
2026-08-15 18:08:54 -07:00

300 lines
8.8 KiB
TypeScript

import { lstat } from 'node:fs/promises'
import { basename, posix, resolve } from 'node:path'
import { authorizeExternalPath } from './filesystem-auth'
import { isENOENT } from './filesystem-path-containment'
import { getSshConnectionManager } from './ssh'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
import type { ImportItemResult } from './filesystem-import-result-types'
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import {
captureLocalUploadRoot,
preScanSshImportDirectory,
uploadSshImportDirectory
} from './filesystem-import-ssh-directory'
// 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,
connectionId: string,
options?: { ensureDir?: boolean; assertCurrent?: () => void }
): Promise<{ results: ImportItemResult[] }> {
if (sourcePaths.length === 0) {
return { results: [] }
}
const connManager = getSshConnectionManager()
const conn = connManager?.getConnection(connectionId)
if (!conn) {
throw new Error(`No SSH connection for "${connectionId}"`)
}
const state = conn.getState()
if (state.status !== 'connected') {
if (state.status === 'reconnecting') {
throw new Error('SSH connection is reconnecting — please try again in a moment')
}
throw new Error('SSH connection is not active — please reconnect and try again')
}
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, options.assertCurrent)
}
const results: ImportItemResult[] = []
const reservedNames = new Set<string>()
if (!provider.openFileUploadSession) {
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
}
options?.assertCurrent?.()
const uploadSession = await provider.openFileUploadSession()
// Why: filename legality follows the remote filesystem, not the client's OS.
const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir)
? 'windows'
: 'posix'
try {
for (const sourcePath of sourcePaths) {
const result = await importOneSourceSsh(
provider,
uploadSession,
sourcePath,
destDir,
reservedNames,
remotePathFlavor,
options?.assertCurrent
)
results.push(result)
if (result.status === 'imported') {
// Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt).
// Node's basename() uses the OS separator, which on Windows would
// return the entire string instead of just the filename.
reservedNames.add(posix.basename(result.destPath))
}
}
} finally {
uploadSession.close()
}
return { results }
}
async function importOneSourceSsh(
provider: IFilesystemProvider,
uploadSession: FileUploadSession,
sourcePath: string,
destDir: string,
reservedNames: Set<string>,
remotePathFlavor: RemotePathFlavor,
assertCurrent?: () => void
): Promise<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
authorizeExternalPath(resolvedSource)
const originalName = basename(resolvedSource)
try {
assertSafeRemotePathSegment(originalName, remotePathFlavor)
} catch (error) {
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
let sourceStat: Awaited<ReturnType<typeof lstat>>
try {
sourceStat = await lstat(resolvedSource)
} catch (error) {
if (isENOENT(error)) {
return { sourcePath, status: 'skipped', reason: 'missing' }
}
if (
error instanceof Error &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EACCES' ||
(error as NodeJS.ErrnoException).code === 'EPERM')
) {
return { sourcePath, status: 'skipped', reason: 'permission-denied' }
}
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
if (sourceStat.isSymbolicLink()) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
return { sourcePath, status: 'skipped', reason: 'unsupported' }
}
const isDir = sourceStat.isDirectory()
let createdDestDir: string | null = null
try {
const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null
if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
// Why: local inspection can outlive a HUB SSH session; revalidate before the first remote write.
assertCurrent?.()
const finalName = await deconflictName(
provider,
destDir,
originalName,
reservedNames,
assertCurrent
)
const destPath = `${destDir}/${finalName}`
const renamed = finalName !== originalName
if (isDir) {
assertCurrent?.()
await provider.createDirNoClobber(destPath)
createdDestDir = destPath
await uploadSshImportDirectory(
provider,
uploadSession,
resolvedSource,
destPath,
rootRealPath!,
remotePathFlavor,
assertCurrent
)
} else {
assertCurrent?.()
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
}
return {
sourcePath,
status: 'imported',
destPath,
kind: isDir ? 'directory' : 'file',
renamed
}
} catch (error) {
if (createdDestDir) {
// Why: local directory imports roll back partial output; SSH imports
// should not leave the no-clobber root after a nested upload failure.
try {
assertCurrent?.()
await provider.deletePath(createdDestDir, true)
} catch {
// Best effort; a replacement session must never inherit cleanup from the retired owner.
}
}
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
}
async function deconflictName(
provider: IFilesystemProvider,
destDir: string,
originalName: string,
reservedNames: Set<string>,
assertCurrent?: () => void
): Promise<string> {
assertCurrent?.()
if (
!(await remotePathExists(provider, `${destDir}/${originalName}`)) &&
!reservedNames.has(originalName)
) {
return originalName
}
const dotIndex = originalName.lastIndexOf('.')
const hasMeaningfulExt = dotIndex > 0
const stem = hasMeaningfulExt ? originalName.slice(0, dotIndex) : originalName
const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : ''
let candidate = `${stem} copy${ext}`
assertCurrent?.()
if (
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
!reservedNames.has(candidate)
) {
return candidate
}
let counter = 2
while (counter < 10000) {
candidate = `${stem} copy ${counter}${ext}`
assertCurrent?.()
if (
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
!reservedNames.has(candidate)
) {
return candidate
}
counter += 1
}
throw new Error(
`Could not generate a unique name for '${originalName}' after ${counter} attempts`
)
}
async function ensureDropStagingDir(
provider: IFilesystemProvider,
destDir: string,
assertCurrent?: () => void
): Promise<void> {
const parent = posix.dirname(destDir)
assertCurrent?.()
await provider.createDir(parent)
const gitignorePath = `${parent}/.gitignore`
assertCurrent?.()
if (!(await remotePathExists(provider, gitignorePath))) {
assertCurrent?.()
await provider.writeFile(gitignorePath, '*\n!.gitignore\n')
}
assertCurrent?.()
await provider.createDir(destDir)
}
async function remotePathExists(
provider: IFilesystemProvider,
remotePath: string
): Promise<boolean> {
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
)
)
}