mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
- Validate each remote path segment (name, symlink target, directory entry) via assertSafeRemotePathSegment before it reaches the remote filesystem, since Windows canonicalizes reserved device names and NTFS streams in ways POSIX checks miss. - Extract directory pre-scan/upload logic into filesystem-import-ssh-directory.ts and add captureLocalUploadRoot to detect a selected root swapped out from under an in-progress import. - Add path-safety tests covering traversal, reserved Windows names, and TOCTOU root replacement.
186 lines
5.2 KiB
TypeScript
186 lines
5.2 KiB
TypeScript
import type { RelayPlatform } from './relay-protocol'
|
|
|
|
export type RemotePathFlavor = 'posix' | 'windows'
|
|
export type RemoteCommandDialect = 'posix' | 'powershell'
|
|
export type RemoteOperatingSystem = 'linux' | 'darwin' | 'win32'
|
|
export type RemoteArchitecture = 'x64' | 'arm64'
|
|
|
|
export type RemoteHostPlatform = {
|
|
relayPlatform: RelayPlatform
|
|
os: RemoteOperatingSystem
|
|
arch: RemoteArchitecture
|
|
pathFlavor: RemotePathFlavor
|
|
commandDialect: RemoteCommandDialect
|
|
pathSeparator: '/' | '\\'
|
|
pathDelimiter: ':' | ';'
|
|
}
|
|
|
|
const PLATFORM_INFO: Record<RelayPlatform, RemoteHostPlatform> = {
|
|
'linux-x64': {
|
|
relayPlatform: 'linux-x64',
|
|
os: 'linux',
|
|
arch: 'x64',
|
|
pathFlavor: 'posix',
|
|
commandDialect: 'posix',
|
|
pathSeparator: '/',
|
|
pathDelimiter: ':'
|
|
},
|
|
'linux-arm64': {
|
|
relayPlatform: 'linux-arm64',
|
|
os: 'linux',
|
|
arch: 'arm64',
|
|
pathFlavor: 'posix',
|
|
commandDialect: 'posix',
|
|
pathSeparator: '/',
|
|
pathDelimiter: ':'
|
|
},
|
|
'darwin-x64': {
|
|
relayPlatform: 'darwin-x64',
|
|
os: 'darwin',
|
|
arch: 'x64',
|
|
pathFlavor: 'posix',
|
|
commandDialect: 'posix',
|
|
pathSeparator: '/',
|
|
pathDelimiter: ':'
|
|
},
|
|
'darwin-arm64': {
|
|
relayPlatform: 'darwin-arm64',
|
|
os: 'darwin',
|
|
arch: 'arm64',
|
|
pathFlavor: 'posix',
|
|
commandDialect: 'posix',
|
|
pathSeparator: '/',
|
|
pathDelimiter: ':'
|
|
},
|
|
'win32-x64': {
|
|
relayPlatform: 'win32-x64',
|
|
os: 'win32',
|
|
arch: 'x64',
|
|
pathFlavor: 'windows',
|
|
commandDialect: 'powershell',
|
|
pathSeparator: '\\',
|
|
pathDelimiter: ';'
|
|
},
|
|
'win32-arm64': {
|
|
relayPlatform: 'win32-arm64',
|
|
os: 'win32',
|
|
arch: 'arm64',
|
|
pathFlavor: 'windows',
|
|
commandDialect: 'powershell',
|
|
pathSeparator: '\\',
|
|
pathDelimiter: ';'
|
|
}
|
|
}
|
|
|
|
export function getRemoteHostPlatform(platform: RelayPlatform): RemoteHostPlatform {
|
|
return PLATFORM_INFO[platform]
|
|
}
|
|
|
|
export function isWindowsRemoteHost(host: RemoteHostPlatform): boolean {
|
|
return host.os === 'win32'
|
|
}
|
|
|
|
export function normalizeWindowsRemotePath(path: string): string {
|
|
return path.replace(/\\/g, '/')
|
|
}
|
|
|
|
export function assertSafeRemotePathSegment(segment: string, pathFlavor: RemotePathFlavor): void {
|
|
if (
|
|
!segment ||
|
|
segment === '.' ||
|
|
segment === '..' ||
|
|
segment.includes('/') ||
|
|
segment.includes('\0')
|
|
) {
|
|
throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`)
|
|
}
|
|
if (pathFlavor === 'posix') {
|
|
return
|
|
}
|
|
|
|
// Why: Win32 canonicalizes names and exposes devices/NTFS streams through
|
|
// otherwise ordinary-looking path segments, bypassing no-clobber checks.
|
|
const normalized = normalizeWindowsRemotePath(segment)
|
|
const deviceStem = normalized.split('.')[0]?.toUpperCase()
|
|
if (
|
|
normalized.includes('/') ||
|
|
hasUnsafeWindowsPathChar(normalized) ||
|
|
/[ .]$/u.test(normalized) ||
|
|
/^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|CLOCK\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(deviceStem ?? '')
|
|
) {
|
|
throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`)
|
|
}
|
|
}
|
|
|
|
function hasUnsafeWindowsPathChar(value: string): boolean {
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
const char = value[i]
|
|
if (value.charCodeAt(i) <= 31 || (char !== undefined && '<>:"|?*'.includes(char))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
export function normalizeRemoteHome(rawHome: string, host: RemoteHostPlatform): string {
|
|
const home = rawHome.trim()
|
|
return isWindowsRemoteHost(host) ? normalizeWindowsRemotePath(home).replace(/\/+$/, '') : home
|
|
}
|
|
|
|
function hasUnsafeRemotePathChar(value: string): boolean {
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
const code = value.charCodeAt(i)
|
|
if (code === 0 || code === 10 || code === 13) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
export function validateRemoteHome(home: string, host: RemoteHostPlatform): boolean {
|
|
if (!home || hasUnsafeRemotePathChar(home)) {
|
|
return false
|
|
}
|
|
if (host.pathFlavor === 'windows') {
|
|
return /^[a-zA-Z]:\//.test(home) || home.startsWith('//')
|
|
}
|
|
return home.startsWith('/')
|
|
}
|
|
|
|
export function joinRemotePath(host: RemoteHostPlatform, ...segments: string[]): string {
|
|
const cleaned = segments.filter(Boolean)
|
|
if (cleaned.length === 0) {
|
|
return ''
|
|
}
|
|
if (host.pathFlavor === 'windows') {
|
|
const [first, ...rest] = cleaned.map((segment) => normalizeWindowsRemotePath(segment))
|
|
return rest.reduce((acc, segment) => {
|
|
const left = acc.replace(/\/+$/, '')
|
|
const right = segment.replace(/^\/+/, '')
|
|
return `${left}/${right}`
|
|
}, first)
|
|
}
|
|
const [first, ...rest] = cleaned
|
|
return rest.reduce((acc, segment) => {
|
|
const left = acc.replace(/\/+$/, '')
|
|
const right = segment.replace(/^\/+/, '')
|
|
return `${left}/${right}`
|
|
}, first)
|
|
}
|
|
|
|
export function remoteBasename(path: string, host: RemoteHostPlatform): string {
|
|
const normalized = host.pathFlavor === 'windows' ? normalizeWindowsRemotePath(path) : path
|
|
return normalized.split('/').findLast(Boolean) ?? ''
|
|
}
|
|
|
|
export function remoteDirname(path: string, host: RemoteHostPlatform): string {
|
|
const normalized = host.pathFlavor === 'windows' ? normalizeWindowsRemotePath(path) : path
|
|
const parts = normalized.split('/')
|
|
parts.pop()
|
|
const joined = parts.join('/')
|
|
if (host.pathFlavor === 'windows') {
|
|
return joined
|
|
}
|
|
return joined || '/'
|
|
}
|