Files
orca/src/main/ssh/sftp-upload.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

253 lines
8.0 KiB
TypeScript

import { constants } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { lstat, open, readdir, realpath } from 'node:fs/promises'
import { isAbsolute, join as pathJoin, relative, sep } from 'node:path'
import type { SFTPWrapper } from 'ssh2'
export function mkdirSftp(
sftp: SFTPWrapper,
path: string,
options?: { allowExisting?: boolean }
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, (err) => {
// Why: SFTP status code 4 (SSH_FX_FAILURE) is a generic code that
// OpenSSH returns for "already exists," but could also cover other
// failures (e.g. permission denied on parent). We accept this ambiguity
// because the next operation (write/recurse) will surface the real error.
if (err && ((err as { code?: number }).code !== 4 || options?.allowExisting === false)) {
reject(err)
} else {
resolve()
}
})
})
}
export function uploadFile(
sftp: SFTPWrapper,
localPath: string,
remotePath: string,
options?: { exclusive?: boolean }
): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
let readStream: ReadStream | null = null
let fileHandle: Awaited<ReturnType<typeof open>> | null = null
let writeStream: ReturnType<SFTPWrapper['createWriteStream']> | null = null
const cleanupListeners = (): void => {
writeStream?.off('close', onWriteClose)
writeStream?.off('error', onWriteError)
readStream?.off('error', onReadError)
}
const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => {
if (settled) {
return
}
settled = true
cleanupListeners()
readStream?.destroy()
writeStream?.destroy()
void fileHandle?.close().catch(() => {})
fn(val as never)
}
const onWriteClose = (): void => settle(resolve)
const onWriteError = (err: Error): void => settle(reject, err)
const onReadError = (err: Error): void => settle(reject, err)
void open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
.then(async (handle) => {
if (settled) {
void handle.close().catch(() => {})
return
}
fileHandle = handle
const statResult = await lstat(localPath)
if (statResult.isSymbolicLink() || !statResult.isFile()) {
throw new Error(`Unsupported upload source: ${localPath}`)
}
const openedStat = await handle.stat()
if (
!openedStat.isFile() ||
openedStat.size !== statResult.size ||
(statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
(statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
) {
throw new Error(`File changed during upload: ${localPath}`)
}
// Why: validate the local source before creating the remote write
// target, so rejected sources do not leave empty files behind.
writeStream = sftp.createWriteStream(remotePath, {
flags: options?.exclusive ? 'wx' : 'w'
})
writeStream.on('close', onWriteClose)
writeStream.on('error', onWriteError)
readStream = handle.createReadStream()
readStream.on('error', onReadError)
readStream.pipe(writeStream)
})
.catch((err: unknown) => settle(reject, err))
})
}
export function uploadBuffer(
sftp: SFTPWrapper,
buffer: Buffer,
remotePath: string,
options?: { append?: boolean; exclusive?: boolean }
): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
const writeStream = sftp.createWriteStream(remotePath, {
flags: options?.append ? 'a' : options?.exclusive ? 'wx' : 'w'
})
const cleanupListeners = (): void => {
writeStream.off('close', onClose)
writeStream.off('error', onError)
}
const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => {
if (settled) {
return
}
settled = true
cleanupListeners()
writeStream.destroy()
fn(val as never)
}
const onClose = (): void => settle(resolve)
const onError = (err: Error): void => settle(reject, err)
writeStream.on('close', onClose)
writeStream.on('error', onError)
writeStream.end(buffer)
})
}
export async function uploadDirectory(
sftp: SFTPWrapper,
localDir: string,
remoteDir: string,
rootRealPath = localDir,
options?: { exclusive?: boolean }
): Promise<void> {
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
const entries = await readdir(localDir, { withFileTypes: true })
for (const entry of entries) {
const localPath = pathJoin(localDir, entry.name)
const remotePath = `${remoteDir}/${entry.name}`
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
const statResult = await lstat(localPath)
// Why: skip symlinks and special files (sockets, FIFOs, devices) to
// prevent following symlinks that could exfiltrate local files to the
// remote. The caller's pre-scan catches symlinks up-front, but this
// guard closes the TOCTOU gap if one is created between scan and upload.
if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
continue
}
if (statResult.isDirectory()) {
await mkdirSftp(sftp, remotePath, { allowExisting: !options?.exclusive })
await uploadDirectory(sftp, localPath, remotePath, rootRealPath, options)
} else {
await uploadFile(sftp, localPath, remotePath, { exclusive: options?.exclusive })
}
}
}
export async function removeDirectorySftp(sftp: SFTPWrapper, remoteDir: string): Promise<void> {
const entries = await readdirSftp(sftp, remoteDir)
const normalizedRemoteDir = remoteDir.replace(/\/+$/, '')
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..') {
continue
}
const childPath = `${normalizedRemoteDir}/${entry.filename}`
await (entry.attrs?.isDirectory?.()
? removeDirectorySftp(sftp, childPath)
: unlinkSftp(sftp, childPath))
}
await rmdirSftp(sftp, remoteDir)
}
type SftpDirectoryEntry = {
filename: string
attrs?: {
isDirectory?: () => boolean
}
}
function readdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise<SftpDirectoryEntry[]> {
return new Promise((resolve, reject) => {
sftp.readdir(remoteDir, (err, entries) => {
if (err) {
reject(err)
return
}
resolve((entries ?? []) as SftpDirectoryEntry[])
})
})
}
function unlinkSftp(sftp: SFTPWrapper, remotePath: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.unlink(remotePath, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
function rmdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rmdir(remoteDir, (err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
}
async function assertLocalUploadPathInsideRoot(
rootRealPath: string,
candidatePath: string
): Promise<void> {
const candidateRealPath = await realpath(candidatePath)
const relativeToRoot = relative(rootRealPath, candidateRealPath)
if (
relativeToRoot !== '' &&
(relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot))
) {
throw new Error(`Path escaped upload root: ${candidatePath}`)
}
}
/**
* Check whether a path exists on the remote via SFTP lstat.
* Returns true if the path exists (file, directory, or symlink).
*/
export function sftpPathExists(sftp: SFTPWrapper, remotePath: string): Promise<boolean> {
return new Promise((resolve, reject) => {
sftp.lstat(remotePath, (err) => {
if (!err) {
resolve(true)
return
}
// Why: SFTP status code 2 = SSH_FX_NO_SUCH_FILE — the path does not
// exist, which is the expected "no collision" signal for deconfliction.
if ((err as { code?: number }).code === 2) {
resolve(false)
return
}
reject(err)
})
})
}