mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
* 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>
253 lines
8.0 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
}
|