Files
goodbuddy/src/main/ssh/bounded-sftp.ts
T
mesalogo 459cc21e3d
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
fix: make managed SSH sessions fast and resilient
Managed SSH project selection repeatedly activated the remote Host and could
leave idle ACP bindings rejected as stale-controller after a controller
generation change. Host environment updates also coupled project state to
component installation evidence and presented a crowded control surface.

Project selection is now local-only, one Host connection serves compatible
project and conversation callers, idle bindings are safely replaced only
after proven controller takeover, and active prompts keep exact resume/replay.
Remote environment setup now uses explicit signed compound package
preparation with bounded transfer, rollback, recovery, and clearer settings.

Managed Hosts must be updated to separately distributed Agent 0.11.6 before
the daemon-side controller recovery fix takes effect. Desktop packages do not
embed the Agent or remote Runtime, and existing project data is preserved.

Release note: 托管 SSH 项目切换不再重复连接,同一 Host 可顺畅承载多个项目和对话;同时修复重连后的 stale-controller,并优化远程环境安装、更新与状态布局。
2026-08-29 10:58:19 +08:00

1020 lines
28 KiB
TypeScript

import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { lstat as localLstat, open as localOpen } from 'node:fs/promises'
import type { SFTPWrapper, Stats } from 'ssh2'
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000
const MAX_OPERATION_TIMEOUT_MS = 60_000
const MINIMUM_TRANSFER_BYTES_PER_SECOND = 64 * 1024
const MAX_TRANSFER_TIMEOUT_MS = 60 * 60_000
const DEFAULT_MAX_FILE_BYTES = 256 * 1024 * 1024
const MAX_MAXIMUM_FILE_BYTES = 512 * 1024 * 1024
const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024
const DEFAULT_MAX_OPERATIONS = 2_048
const MAX_REMOTE_PATH_BYTES = 4_096
const MAX_REMOTE_PATH_SEGMENTS = 128
const READ_CHUNK_BYTES = 64 * 1024
const MAXIMUM_CONCURRENT_READS = 32
const ALLOWED_MODES = [0o600, 0o644, 0o700, 0o755] as const
export type BoundedSftpLimits = {
maximumFileBytes?: number
maximumTotalBytes?: number
maximumOperations?: number
operationTimeoutMs?: number
}
export type SftpEntryType =
| 'file'
| 'directory'
| 'symbolic-link'
export type SftpEntryMetadata = {
type: SftpEntryType
size: number
mode: number
uid: number
gid: number
atime: number
mtime: number
}
export type AllowedSftpMode = (typeof ALLOWED_MODES)[number]
export type SftpUploadIdentity = {
size: number
sha256: string
}
export type StagedSftp = {
readonly stagingDirectory: string
mkdir(relativePath: string, signal?: AbortSignal): Promise<void>
writeFile(
relativePath: string,
contents: Buffer,
signal?: AbortSignal
): Promise<void>
uploadFile(
relativePath: string,
sourcePath: string,
identity: SftpUploadIdentity,
signal?: AbortSignal
): Promise<void>
readFile(
relativePath: string,
signal?: AbortSignal
): Promise<Buffer>
lstat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata>
stat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata>
chmod(
relativePath: string,
mode: AllowedSftpMode,
signal?: AbortSignal
): Promise<void>
setExecutable(
relativePath: string,
signal?: AbortSignal
): Promise<void>
rename(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
replaceFile(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
hardLink?(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
unlink(relativePath: string, signal?: AbortSignal): Promise<void>
rmdir(relativePath: string, signal?: AbortSignal): Promise<void>
close(): void
}
type SftpLike = Pick<
SFTPWrapper,
| 'mkdir'
| 'writeFile'
| 'chmod'
| 'rename'
| 'ext_openssh_rename'
| 'ext_openssh_hardlink'
| 'unlink'
| 'rmdir'
| 'lstat'
| 'stat'
| 'open'
| 'read'
| 'write'
| 'close'
| 'fstat'
| 'end'
| 'on'
>
function validatePositiveLimit(
value: number | undefined,
fallback: number,
maximum: number
): number {
const resolved = value ?? fallback
if (
!Number.isSafeInteger(resolved) ||
resolved <= 0 ||
resolved > maximum
) {
throw new Error('SFTP 安全限制无效')
}
return resolved
}
function validateStagingDirectory(value: string): string {
if (
Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES ||
value.includes('\0') ||
value.includes('\\') ||
value.includes('//') ||
!(value.startsWith('/') || /^[A-Za-z]:\//u.test(value))
) {
throw new Error('SFTP 暂存目录无效')
}
const normalized = value.replace(/\/+$/u, '')
const pathWithoutRoot = normalized.replace(
/^(?:\/|[A-Za-z]:\/)/u,
''
)
const parts = pathWithoutRoot.split('/')
if (
!pathWithoutRoot ||
parts.length > MAX_REMOTE_PATH_SEGMENTS ||
parts.some((part) => !part || part === '.' || part === '..')
) {
throw new Error('SFTP 暂存目录无效')
}
return normalized
}
function validateRelativePath(value: string): string {
if (
!value ||
Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES ||
value.includes('\0') ||
value.includes('\\') ||
value.startsWith('/') ||
/^[A-Za-z]:/u.test(value)
) {
throw new Error('SFTP 相对路径无效')
}
const parts = value.split('/')
if (
parts.length > MAX_REMOTE_PATH_SEGMENTS ||
parts.some((part) => !part || part === '.' || part === '..')
) {
throw new Error('SFTP 相对路径无效')
}
return value
}
function abortError(signal?: AbortSignal): unknown {
return (
signal?.reason ??
new DOMException('The operation was aborted', 'AbortError')
)
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
('code' in error &&
((error as { code?: unknown }).code === 2 ||
(error as { code?: unknown }).code === 'ENOENT'))
)
}
function metadataFromStats(stats: Stats): SftpEntryMetadata {
if (
!Number.isSafeInteger(stats.size) ||
stats.size < 0 ||
!Number.isSafeInteger(stats.mode) ||
!Number.isSafeInteger(stats.uid) ||
!Number.isSafeInteger(stats.gid) ||
!Number.isFinite(stats.atime) ||
!Number.isFinite(stats.mtime)
) {
throw new Error('SFTP 文件元数据无效')
}
const predicates = [
['file', stats.isFile],
['directory', stats.isDirectory],
['symbolic-link', stats.isSymbolicLink]
] as const
const types = predicates
.filter(([, predicate]) => {
if (typeof predicate !== 'function') {
throw new Error('SFTP 文件类型元数据无效')
}
return predicate.call(stats)
})
.map(([type]) => type)
if (types.length !== 1) {
throw new Error('SFTP 文件类型不受支持')
}
return {
type: types[0]!,
size: stats.size,
mode: stats.mode & 0o7777,
uid: stats.uid,
gid: stats.gid,
atime: stats.atime,
mtime: stats.mtime
}
}
export class BoundedStagedSftp implements StagedSftp {
readonly stagingDirectory: string
private readonly maximumFileBytes: number
private readonly maximumTotalBytes: number
private readonly maximumOperations: number
private readonly operationTimeoutMs: number
private totalBytes = 0
private operations = 0
private closed = false
private queue: Promise<void> = Promise.resolve()
constructor(
private readonly sftp: SftpLike,
stagingDirectory: string,
limits: BoundedSftpLimits = {}
) {
this.stagingDirectory =
validateStagingDirectory(stagingDirectory)
this.maximumFileBytes = validatePositiveLimit(
limits.maximumFileBytes,
DEFAULT_MAX_FILE_BYTES,
MAX_MAXIMUM_FILE_BYTES
)
this.maximumTotalBytes = validatePositiveLimit(
limits.maximumTotalBytes,
DEFAULT_MAX_TOTAL_BYTES,
DEFAULT_MAX_TOTAL_BYTES
)
this.maximumOperations = validatePositiveLimit(
limits.maximumOperations,
DEFAULT_MAX_OPERATIONS,
DEFAULT_MAX_OPERATIONS
)
this.operationTimeoutMs = validatePositiveLimit(
limits.operationTimeoutMs,
DEFAULT_OPERATION_TIMEOUT_MS,
MAX_OPERATION_TIMEOUT_MS
)
this.sftp.on('error', () => {
this.closed = true
})
}
async mkdir(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
await this.call<void>((done) => {
this.sftp.mkdir(remotePath, { mode: 0o700 }, done)
})
})
}
async writeFile(
relativePath: string,
contents: Buffer,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
if (contents.byteLength > this.maximumFileBytes) {
throw new Error('SFTP 文件大小超过安全限制')
}
return this.enqueue(
signal,
async () => {
if (
this.totalBytes + contents.byteLength >
this.maximumTotalBytes
) {
throw new Error('SFTP 传输总量超过安全限制')
}
await this.assertSafeAncestors(validated)
await this.call<void>((done) => {
this.sftp.writeFile(
remotePath,
contents,
{ mode: 0o600, flag: 'wx' },
done
)
})
this.totalBytes += contents.byteLength
},
this.transferTimeoutMs(contents.byteLength)
)
}
async uploadFile(
relativePath: string,
sourcePath: string,
identity: SftpUploadIdentity,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
if (
!Number.isSafeInteger(identity.size) ||
identity.size < 0 ||
identity.size > this.maximumFileBytes
) {
throw new Error('SFTP 文件大小超过安全限制')
}
if (!/^[a-f0-9]{64}$/u.test(identity.sha256)) {
throw new Error('SFTP 文件 SHA-256 无效')
}
return this.enqueue(
signal,
async () => {
if (
this.totalBytes + identity.size >
this.maximumTotalBytes
) {
throw new Error('SFTP 传输总量超过安全限制')
}
await this.assertSafeAncestors(validated)
const pathMetadata = await localLstat(sourcePath)
if (
!pathMetadata.isFile() ||
pathMetadata.isSymbolicLink() ||
pathMetadata.size !== identity.size
) {
throw new Error('SFTP 本地上传源不是预期的普通文件')
}
const source = await localOpen(
sourcePath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
let remoteHandle: Buffer | undefined
const closeSourceOnAbort = (): void => {
void source.close().catch(() => undefined)
}
signal?.addEventListener('abort', closeSourceOnAbort, {
once: true
})
try {
const openedMetadata = await source.stat()
if (
!openedMetadata.isFile() ||
openedMetadata.size !== identity.size ||
openedMetadata.dev !== pathMetadata.dev ||
openedMetadata.ino !== pathMetadata.ino
) {
throw new Error(
'SFTP 本地上传源在打开期间发生变化'
)
}
remoteHandle = await this.call<Buffer>((done) => {
this.sftp.open(
remotePath,
'wx',
{ mode: 0o600 },
done
)
})
const initialRemoteMetadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.fstat(remoteHandle!, done)
})
)
this.assertType(initialRemoteMetadata, 'file')
if (initialRemoteMetadata.size !== 0) {
throw new Error('SFTP 上传目标初始大小无效')
}
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES)
let position = 0
while (true) {
signal?.throwIfAborted()
const { bytesRead } = await source.read(
buffer,
0,
buffer.byteLength,
position
)
if (bytesRead === 0) {
break
}
position += bytesRead
if (
position > identity.size ||
this.totalBytes + bytesRead >
this.maximumTotalBytes
) {
throw new Error('SFTP 文件大小超过安全限制')
}
const chunk = buffer.subarray(0, bytesRead)
hash.update(chunk)
await this.writeHandle(
remoteHandle,
chunk,
position - bytesRead
)
this.totalBytes += bytesRead
}
if (position !== identity.size) {
throw new Error('SFTP 本地上传源大小与预期不一致')
}
if (hash.digest('hex') !== identity.sha256) {
throw new Error('SFTP 本地上传源 SHA-256 校验失败')
}
const remoteMetadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.fstat(remoteHandle!, done)
})
)
this.assertType(remoteMetadata, 'file')
if (remoteMetadata.size !== identity.size) {
throw new Error('SFTP 上传目标大小与预期不一致')
}
} finally {
signal?.removeEventListener(
'abort',
closeSourceOnAbort
)
await Promise.allSettled([
source.close(),
remoteHandle
? this.closeHandle(remoteHandle)
: Promise.resolve()
])
}
},
this.transferTimeoutMs(identity.size)
)
}
async readFile(
relativePath: string,
signal?: AbortSignal
): Promise<Buffer> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(
signal,
async (resetTimeout) => {
const metadata = await this.safeStat(validated)
this.assertType(metadata, 'file')
const remainingTotal =
this.maximumTotalBytes - this.totalBytes
const maximumReadBytes = Math.min(
this.maximumFileBytes,
remainingTotal
)
if (
maximumReadBytes <= 0 ||
metadata.size > maximumReadBytes
) {
throw new Error('SFTP 读取大小超过安全限制')
}
const handle = await this.call<Buffer>((done) => {
this.sftp.open(remotePath, 'r', done)
})
try {
const openedMetadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.fstat(handle, done)
})
)
this.assertType(openedMetadata, 'file')
if (openedMetadata.size > maximumReadBytes) {
throw new Error('SFTP 读取大小超过安全限制')
}
resetTimeout(
this.transferTimeoutMs(openedMetadata.size)
)
const contents = await this.readHandle(
handle,
openedMetadata.size,
maximumReadBytes
)
this.totalBytes += contents.byteLength
return contents
} finally {
await this.closeHandle(handle)
}
}
)
}
async lstat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata> {
const validated = validateRelativePath(relativePath)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
return this.getLstat(this.resolveValidated(validated))
})
}
async stat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata> {
const validated = validateRelativePath(relativePath)
return this.enqueue(signal, () => this.safeStat(validated))
}
async chmod(
relativePath: string,
mode: AllowedSftpMode,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
if (!(ALLOWED_MODES as readonly number[]).includes(mode)) {
throw new Error('SFTP 文件权限不在安全允许列表中')
}
return this.enqueue(signal, async () => {
const metadata = await this.safeStat(validated)
if (
metadata.type === 'directory' &&
mode !== 0o700 &&
mode !== 0o755
) {
throw new Error('SFTP 目录权限必须保留执行权限')
}
await this.call<void>((done) => {
this.sftp.chmod(remotePath, mode, done)
})
})
}
setExecutable(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
return this.chmod(relativePath, 0o700, signal)
}
async rename(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
const sourceMetadata = await this.getLstat(source)
this.assertNotLink(sourceMetadata)
await this.assertSafeAncestors(destinationValidated)
try {
await this.getLstat(destination)
throw new Error('SFTP 重命名目标已存在')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await this.call<void>((done) => {
this.sftp.rename(source, destination, done)
})
})
}
async replaceFile(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
this.assertType(await this.getLstat(source), 'file')
await this.assertSafeAncestors(destinationValidated)
try {
this.assertType(await this.getLstat(destination), 'file')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
try {
await this.call<void>((done) => {
this.sftp.ext_openssh_rename(
source,
destination,
done
)
})
} catch (error) {
throw new Error('SFTP 原子文件替换失败', {
cause: error
})
}
})
}
async hardLink(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
this.assertType(await this.getLstat(source), 'file')
await this.assertSafeAncestors(destinationValidated)
try {
await this.getLstat(destination)
throw new Error('SFTP 硬链接目标已存在')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await this.call<void>((done) => {
this.sftp.ext_openssh_hardlink(
source,
destination,
done
)
})
this.assertType(await this.getLstat(destination), 'file')
})
}
async unlink(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
this.assertType(await this.getLstat(remotePath), 'file')
await this.call<void>((done) => {
this.sftp.unlink(remotePath, done)
})
})
}
async rmdir(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
this.assertType(
await this.getLstat(remotePath),
'directory'
)
await this.call<void>((done) => {
this.sftp.rmdir(remotePath, done)
})
})
}
close(): void {
if (this.closed) {
return
}
this.closed = true
this.sftp.end()
}
private resolveValidated(relativePath: string): string {
return `${this.stagingDirectory}/${relativePath}`
}
private async assertSafeAncestors(
relativePath: string
): Promise<void> {
const parts = relativePath.split('/')
const paths = [this.stagingDirectory]
let current = this.stagingDirectory
for (const part of parts.slice(0, -1)) {
current = `${current}/${part}`
paths.push(current)
}
for (const path of paths) {
this.assertType(await this.getLstat(path), 'directory')
}
}
private async safeStat(
relativePath: string
): Promise<SftpEntryMetadata> {
await this.assertSafeAncestors(relativePath)
const remotePath = this.resolveValidated(relativePath)
const linkMetadata = await this.getLstat(remotePath)
this.assertNotLink(linkMetadata)
const metadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.stat(remotePath, done)
})
)
this.assertNotLink(metadata)
if (metadata.type !== linkMetadata.type) {
throw new Error('SFTP 文件类型在操作期间发生变化')
}
return metadata
}
private getLstat(remotePath: string): Promise<SftpEntryMetadata> {
return this.call<Stats>((done) => {
this.sftp.lstat(remotePath, done)
}).then(metadataFromStats)
}
private assertNotLink(metadata: SftpEntryMetadata): void {
if (metadata.type === 'symbolic-link') {
throw new Error('SFTP 不允许操作符号链接')
}
}
private assertType(
metadata: SftpEntryMetadata,
expected: Exclude<SftpEntryType, 'symbolic-link'>
): void {
this.assertNotLink(metadata)
if (metadata.type !== expected) {
throw new Error(
expected === 'file'
? 'SFTP 目标不是普通文件'
: 'SFTP 目标不是目录'
)
}
}
private async readHandle(
handle: Buffer,
expectedSize: number,
maximumReadBytes: number
): Promise<Buffer> {
const contents = Buffer.allocUnsafe(expectedSize)
let nextPosition = 0
let discoveredSize = expectedSize
const readSegment = async (): Promise<void> => {
while (true) {
const position = nextPosition
nextPosition += READ_CHUNK_BYTES
if (
position >= expectedSize ||
position >= discoveredSize
) {
return
}
const segmentLength = Math.min(
READ_CHUNK_BYTES,
expectedSize - position
)
let segmentOffset = 0
while (
segmentOffset < segmentLength &&
position + segmentOffset < discoveredSize
) {
const length = segmentLength - segmentOffset
const bytesRead = await this.call<number>((done) => {
this.sftp.read(
handle,
contents,
position + segmentOffset,
length,
position + segmentOffset,
(error, count) => done(error, count)
)
})
if (
!Number.isSafeInteger(bytesRead) ||
bytesRead < 0 ||
bytesRead > length
) {
throw new Error('SFTP 读取结果无效')
}
if (bytesRead === 0) {
discoveredSize = Math.min(
discoveredSize,
position + segmentOffset
)
return
}
segmentOffset += bytesRead
}
}
}
const workerCount = Math.min(
MAXIMUM_CONCURRENT_READS,
Math.ceil(expectedSize / READ_CHUNK_BYTES)
)
await Promise.all(
Array.from({ length: workerCount }, readSegment)
)
const extra = Buffer.allocUnsafe(1)
const extraBytes = await this.call<number>((done) => {
this.sftp.read(
handle,
extra,
0,
1,
expectedSize,
(error, count) => done(error, count)
)
})
if (extraBytes !== 0) {
throw new Error('SFTP 读取大小超过安全限制')
}
const actualSize = Math.min(expectedSize, discoveredSize)
if (actualSize > maximumReadBytes) {
throw new Error('SFTP 读取大小超过安全限制')
}
return actualSize === contents.byteLength
? contents
: contents.subarray(0, actualSize)
}
private async writeHandle(
handle: Buffer,
contents: Buffer,
position: number
): Promise<void> {
await this.call<void>((done) => {
this.sftp.write(
handle,
contents,
0,
contents.byteLength,
position,
done
)
})
}
private async closeHandle(handle: Buffer): Promise<void> {
try {
await this.call<void>((done) => {
this.sftp.close(handle, done)
})
} catch {
this.close()
}
}
private call<T>(
begin: (
callback: (error?: Error | null, value?: T) => void
) => void
): Promise<T> {
if (this.closed) {
return Promise.reject(new Error('SFTP 通道已关闭'))
}
return new Promise((resolve, reject) => {
try {
begin((error, value) => {
if (error) {
reject(error)
} else {
resolve(value as T)
}
})
} catch (error) {
reject(error)
}
})
}
private transferTimeoutMs(byteLength: number): number {
const scaled = Math.ceil(
(byteLength / MINIMUM_TRANSFER_BYTES_PER_SECOND) * 1_000
)
return Math.min(
MAX_TRANSFER_TIMEOUT_MS,
Math.max(this.operationTimeoutMs, scaled)
)
}
private enqueue<T>(
signal: AbortSignal | undefined,
operation: (
resetTimeout: (timeoutMs: number) => void
) => Promise<T>,
timeoutMs = this.operationTimeoutMs
): Promise<T> {
if (this.closed) {
return Promise.reject(new Error('SFTP 通道已关闭'))
}
if (signal?.aborted) {
return Promise.reject(abortError(signal))
}
this.operations += 1
if (this.operations > this.maximumOperations) {
return Promise.reject(
new Error('SFTP 操作数量超过安全限制')
)
}
const previous = this.queue
let releaseQueue!: () => void
this.queue = new Promise<void>((resolve) => {
releaseQueue = resolve
})
return new Promise<T>((resolve, reject) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | undefined
const finish = (value?: T, error?: unknown): void => {
if (settled) {
return
}
settled = true
if (timeout !== undefined) {
clearTimeout(timeout)
}
signal?.removeEventListener('abort', abort)
releaseQueue()
if (error) {
reject(error)
} else {
resolve(value as T)
}
}
const abort = (): void => {
this.close()
finish(undefined, abortError(signal))
}
const resetTimeout = (nextTimeoutMs: number): void => {
if (settled) {
return
}
if (timeout !== undefined) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
this.close()
finish(undefined, new Error('SFTP 操作超时'))
}, nextTimeoutMs)
}
const start = (): void => {
if (settled) {
return
}
if (this.closed) {
finish(undefined, new Error('SFTP 通道已关闭'))
return
}
if (signal?.aborted) {
abort()
return
}
resetTimeout(timeoutMs)
Promise.resolve()
.then(() => operation(resetTimeout))
.then(
(value) => finish(value),
(error: unknown) => finish(undefined, error)
)
}
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) {
abort()
return
}
previous.then(start, start)
})
}
}