@tiphareth/dsh-hardssh 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'node:stream'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
4
|
+
import { Client, type SFTPWrapper, type Stats } from 'ssh2'
|
|
5
|
+
import type { ClientLease } from '../connection/lease.ts'
|
|
6
|
+
import { createTransferProgressTracker } from '../transfer/progress.ts'
|
|
7
|
+
import type { RemoteDirEntry, TransferProgress } from '../protocol.ts'
|
|
8
|
+
|
|
9
|
+
/** Lets an SFTP operation declare the point after which replay is unsafe. */
|
|
10
|
+
export interface SftpOperationControl {
|
|
11
|
+
markCommitted(): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Options for one access.withClient() call. */
|
|
15
|
+
export interface SftpClientOptions {
|
|
16
|
+
/** Total acquire+operation attempt budget (default 3). */
|
|
17
|
+
attempts?: number
|
|
18
|
+
retryPolicy?: 'never' | 'connect-only' | 'idempotent'
|
|
19
|
+
signal?: AbortSignal
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Narrow connection dependency of the SFTP component: the pooled lease
|
|
24
|
+
* acquisition plus the engine's retry/replay policy. The component owns the
|
|
25
|
+
* lease for the whole operation (including the lifetime of a returned read
|
|
26
|
+
* stream) and never reaches into the pool's bookkeeping.
|
|
27
|
+
*/
|
|
28
|
+
export interface SftpClientAccess {
|
|
29
|
+
withClient<T>(
|
|
30
|
+
alias: string,
|
|
31
|
+
fn: (client: Client, control: SftpOperationControl) => Promise<T>,
|
|
32
|
+
options?: SftpClientOptions,
|
|
33
|
+
): Promise<T>
|
|
34
|
+
acquire(alias: string, options: { kind: 'operation' | 'stream'; signal?: AbortSignal }): Promise<ClientLease>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The engine knobs the SFTP component reads. Keyed exactly like the engine's
|
|
39
|
+
* resolved options, so every SFTP deadline/concurrency keeps one source of
|
|
40
|
+
* truth.
|
|
41
|
+
*/
|
|
42
|
+
export interface SftpOptions {
|
|
43
|
+
sftpConcurrency: number
|
|
44
|
+
sftpOpenTimeoutMs: number
|
|
45
|
+
sftpOperationTimeoutMs: number
|
|
46
|
+
sftpReadTimeoutMs: number
|
|
47
|
+
maxReadFileBytes: number
|
|
48
|
+
sftpTransferIdleTimeoutMs: number
|
|
49
|
+
sftpRecursiveRmTimeoutMs: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One cached SFTP subsystem channel for a live pooled client. */
|
|
53
|
+
interface SftpCacheEntry {
|
|
54
|
+
promise: Promise<SFTPWrapper>
|
|
55
|
+
wrapper?: SFTPWrapper
|
|
56
|
+
/** Owner client, so a timed-out request can find and rotate its channel. */
|
|
57
|
+
client: Client
|
|
58
|
+
/** Requests currently riding this channel. The channel is shared, so a
|
|
59
|
+
* per-request timeout must NOT close it while others are still in flight. */
|
|
60
|
+
inFlight: number
|
|
61
|
+
/** A request timed out while others were in flight: rotate once idle. */
|
|
62
|
+
suspect: boolean
|
|
63
|
+
cancel(error: Error): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isMissingSftpError(error: unknown): boolean {
|
|
67
|
+
const code = String((error as { code?: unknown } | undefined)?.code ?? '')
|
|
68
|
+
return /NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${String(error)}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Symlink stat batch width — parallelized so a dir full of links (conda /
|
|
72
|
+
* venv bin, node_modules/.bin) costs a handful of round-trips, not N. ssh2's
|
|
73
|
+
* SFTP window pipelines requests, so one batch ≈ one round-trip. */
|
|
74
|
+
const SYMLINK_STAT_BATCH = 64
|
|
75
|
+
|
|
76
|
+
function walkLocalDir(root: string): string[] {
|
|
77
|
+
const files: string[] = []
|
|
78
|
+
const visit = (dir: string): void => {
|
|
79
|
+
for (const name of readdirSync(dir)) {
|
|
80
|
+
const full = join(dir, name)
|
|
81
|
+
const stat = statSync(full)
|
|
82
|
+
if (stat.isDirectory()) visit(full)
|
|
83
|
+
else if (stat.isFile()) files.push(relative(root, full))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
visit(root)
|
|
87
|
+
return files
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Owns every SFTP operation for the engine: one cached subsystem channel per
|
|
92
|
+
* live pooled client (plus its cancellation), the per-request deadlines, and
|
|
93
|
+
* the upload/download/ls/stat/read/write/mkdir/rm/rename surface.
|
|
94
|
+
*/
|
|
95
|
+
export class SftpService {
|
|
96
|
+
/**
|
|
97
|
+
* One cached SFTP subsystem channel per live client. `Client.sftp()` opens a
|
|
98
|
+
* NEW subsystem channel on every call and OpenSSH caps open sessions per
|
|
99
|
+
* connection (MaxSessions, default 10) —reopening SFTP per operation lets
|
|
100
|
+
* channels pile up on the pooled long-lived connection until listing/reading
|
|
101
|
+
* fails intermittently. Caching one channel per client fixes that; the pool
|
|
102
|
+
* drops the cache via onClientDisposed when a connection is torn down.
|
|
103
|
+
*/
|
|
104
|
+
private readonly cache = new Map<Client, SftpCacheEntry>()
|
|
105
|
+
|
|
106
|
+
constructor(
|
|
107
|
+
private readonly access: SftpClientAccess,
|
|
108
|
+
private readonly sftpOpts: SftpOptions,
|
|
109
|
+
) {}
|
|
110
|
+
/** Upload one local file (or directory tree) to a remote path. */
|
|
111
|
+
async upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number; files: number }> {
|
|
112
|
+
// on one resolution (relative paths previously created dirs at the root).
|
|
113
|
+
if (!remotePath.startsWith('/')) {
|
|
114
|
+
throw new Error(`remotePath must be an absolute path (got '${remotePath}')`)
|
|
115
|
+
}
|
|
116
|
+
const local = resolvePath(localPath)
|
|
117
|
+
if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`)
|
|
118
|
+
return this.access.withClient(alias, async (client) => {
|
|
119
|
+
const sftp = await this.sftpFor(client)
|
|
120
|
+
const stat = statSync(local)
|
|
121
|
+
let files: string[]
|
|
122
|
+
if (stat.isDirectory()) {
|
|
123
|
+
if (!recursive) throw new Error(`'${localPath}' is a directory —enable recursive upload`)
|
|
124
|
+
files = walkLocalDir(local)
|
|
125
|
+
await this.ensureRemoteDir(sftp, remotePath)
|
|
126
|
+
} else {
|
|
127
|
+
files = ['']
|
|
128
|
+
await this.ensureRemoteDir(sftp, dirname(remotePath))
|
|
129
|
+
}
|
|
130
|
+
let bytes = 0
|
|
131
|
+
for (const rel of files) {
|
|
132
|
+
const src = rel === '' ? local : join(local, rel)
|
|
133
|
+
// Remote paths always use forward slashes; normalize any OS separators.
|
|
134
|
+
const remoteRel = rel.split(/[\\/]/).join('/')
|
|
135
|
+
const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
|
|
136
|
+
await this.fastPut(sftp, src, dst, onProgress)
|
|
137
|
+
bytes += statSync(src).size
|
|
138
|
+
}
|
|
139
|
+
return { bytes, files: files.length }
|
|
140
|
+
}, { signal })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Download one remote file to a local path. */
|
|
144
|
+
async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number }> {
|
|
145
|
+
return this.access.withClient(alias, async (client) => {
|
|
146
|
+
const sftp = await this.sftpFor(client)
|
|
147
|
+
const stats = await this.sftpStat(sftp, remotePath)
|
|
148
|
+
if (stats.isDirectory()) {
|
|
149
|
+
throw new Error(`'${remotePath}' is a directory —directory download is not supported yet (download individual files)`)
|
|
150
|
+
}
|
|
151
|
+
const local = resolvePath(localPath)
|
|
152
|
+
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
|
|
153
|
+
await this.fastGet(sftp, remotePath, local, stats.size, onProgress)
|
|
154
|
+
return { bytes: statSync(local).size }
|
|
155
|
+
}, { signal })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** List a remote directory (file browser). Bounded by a timeout so a
|
|
159
|
+
* stalled SFTP request fails instead of leaving the file tree spinning. */
|
|
160
|
+
async ls(alias: string, path: string, signal?: AbortSignal): Promise<import('../protocol.ts').RemoteDirEntry[]> {
|
|
161
|
+
return this.access.withClient(alias, async (client) => {
|
|
162
|
+
const sftp = await this.sftpFor(client)
|
|
163
|
+
// readdir/stat requests have no per-request cancel in ssh2; the timeout
|
|
164
|
+
// only stops the caller. withClient marks the lease broken and the
|
|
165
|
+
// release() (single holder) reaps the transport, so a hung request
|
|
166
|
+
// dies with the connection instead of lingering.
|
|
167
|
+
return this.withSftpTimeout(
|
|
168
|
+
sftp,
|
|
169
|
+
(async () => {
|
|
170
|
+
const list = await new Promise<Array<{ filename: string; attrs: import('ssh2').Stats }>>((resolve, reject) => {
|
|
171
|
+
sftp.readdir(path, (error, items) => error !== undefined ? reject(error) : resolve(items))
|
|
172
|
+
})
|
|
173
|
+
return this.classifyEntries(sftp, path, list)
|
|
174
|
+
})(),
|
|
175
|
+
this.sftpOpts.sftpOperationTimeoutMs,
|
|
176
|
+
`remote ls timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${path}`,
|
|
177
|
+
)
|
|
178
|
+
}, { signal })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve many remote paths to their canonical form in one SFTP pass
|
|
183
|
+
* (P1-26): one lease + one batch of `sftp.realpath` calls instead of N
|
|
184
|
+
* `realpath` execs. A path that cannot resolve (dangling symlink, vanished
|
|
185
|
+
* entry) fails the whole batch — callers treat an unresolvable listing as
|
|
186
|
+
* an error rather than silently using an uncanonical path.
|
|
187
|
+
*/
|
|
188
|
+
async realpaths(alias: string, remotePaths: readonly string[], signal?: AbortSignal): Promise<string[]> {
|
|
189
|
+
if (remotePaths.length === 0) return []
|
|
190
|
+
return this.access.withClient(alias, async (client) => {
|
|
191
|
+
const sftp = await this.sftpFor(client)
|
|
192
|
+
const results = new Array<string>(remotePaths.length)
|
|
193
|
+
for (let start = 0; start < remotePaths.length; start += SYMLINK_STAT_BATCH) {
|
|
194
|
+
const batch = remotePaths.slice(start, start + SYMLINK_STAT_BATCH)
|
|
195
|
+
const resolved = await Promise.all(batch.map((path) => this.withSftpTimeout(
|
|
196
|
+
sftp,
|
|
197
|
+
new Promise<string>((resolve, reject) => {
|
|
198
|
+
sftp.realpath(path, (error, canonical) => error !== undefined ? reject(error) : resolve(canonical))
|
|
199
|
+
}),
|
|
200
|
+
this.sftpOpts.sftpOperationTimeoutMs,
|
|
201
|
+
`remote realpath timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${path}`,
|
|
202
|
+
)))
|
|
203
|
+
for (let i = 0; i < batch.length; i += 1) results[start + i] = resolved[i]!
|
|
204
|
+
}
|
|
205
|
+
return results
|
|
206
|
+
}, { signal })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Classify readdir entries, following symlinks so a link to a directory
|
|
211
|
+
* (e.g. AutoDL's /root/autodl-tmp) lists as a directory instead of 'other'.
|
|
212
|
+
* Symlinks are stat'd in PARALLEL batches: serializing them turns a conda /
|
|
213
|
+
* venv bin full of links into N round-trips (seconds to tens of seconds on a
|
|
214
|
+
* slow link) — batching keeps it to a handful of round-trips. The whole pass
|
|
215
|
+
* is bounded by ls()'s timeout.
|
|
216
|
+
*/
|
|
217
|
+
async classifyEntries(
|
|
218
|
+
sftp: import('ssh2').SFTPWrapper,
|
|
219
|
+
dirPath: string,
|
|
220
|
+
list: Array<{ filename: string; attrs: import('ssh2').Stats }>,
|
|
221
|
+
): Promise<import('../protocol.ts').RemoteDirEntry[]> {
|
|
222
|
+
const resolved = new Array<'dir' | 'file' | 'other' | null>(list.length).fill(null)
|
|
223
|
+
const linkIndexes = list
|
|
224
|
+
.map((item, index) => (item.attrs.isSymbolicLink() ? index : -1))
|
|
225
|
+
.filter((index) => index >= 0)
|
|
226
|
+
const base = dirPath.replace(/\/+$/, '')
|
|
227
|
+
for (let start = 0; start < linkIndexes.length; start += SYMLINK_STAT_BATCH) {
|
|
228
|
+
const batch = linkIndexes.slice(start, start + SYMLINK_STAT_BATCH)
|
|
229
|
+
await Promise.all(batch.map(async (index) => {
|
|
230
|
+
try {
|
|
231
|
+
const stats = await new Promise<import('ssh2').Stats>((res, rej) => {
|
|
232
|
+
sftp.stat(`${base}/${list[index].filename}`, (statError, stats) => statError !== undefined ? rej(statError) : res(stats))
|
|
233
|
+
})
|
|
234
|
+
resolved[index] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : 'other'
|
|
235
|
+
} catch {
|
|
236
|
+
resolved[index] = 'other' // dangling link
|
|
237
|
+
}
|
|
238
|
+
}))
|
|
239
|
+
}
|
|
240
|
+
return list.map((item, index): import('../protocol.ts').RemoteDirEntry => {
|
|
241
|
+
let type: 'dir' | 'file' | 'other' = item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other'
|
|
242
|
+
if (type === 'other' && item.attrs.isSymbolicLink()) type = resolved[index] ?? 'other'
|
|
243
|
+
return { name: item.filename, type, size: item.attrs.size, mtimeMs: item.attrs.mtime * 1000, mode: item.attrs.mode }
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Stat one remote path (file browser / conflict checks). Bounded by a timeout. */
|
|
248
|
+
async stat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> {
|
|
249
|
+
return this.access.withClient(alias, async (client) => {
|
|
250
|
+
const sftp = await this.sftpFor(client)
|
|
251
|
+
const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), this.sftpOpts.sftpOperationTimeoutMs, `remote stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
|
|
252
|
+
return {
|
|
253
|
+
type: attrs.isDirectory() ? 'dir' : attrs.isFile() ? 'file' : 'other',
|
|
254
|
+
size: attrs.size,
|
|
255
|
+
mtimeMs: attrs.mtime * 1000,
|
|
256
|
+
mode: attrs.mode,
|
|
257
|
+
}
|
|
258
|
+
}, { signal })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Lstat one remote path without following the final symlink. Returns
|
|
263
|
+
* undefined when the path is absent (the fs seam's lstat contract).
|
|
264
|
+
*/
|
|
265
|
+
async lstat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtimeMs: number; mode: number } | undefined> {
|
|
266
|
+
return this.access.withClient(alias, async (client) => {
|
|
267
|
+
const sftp = await this.sftpFor(client)
|
|
268
|
+
try {
|
|
269
|
+
const attrs = await this.sftpLstat(sftp, remotePath)
|
|
270
|
+
return {
|
|
271
|
+
type: attrs.isSymbolicLink() ? 'symlink' : attrs.isDirectory() ? 'directory' : attrs.isFile() ? 'file' : 'other',
|
|
272
|
+
size: attrs.size,
|
|
273
|
+
mtimeMs: attrs.mtime * 1000,
|
|
274
|
+
mode: attrs.mode,
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (isMissingSftpError(error)) return undefined
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
280
|
+
}, { signal })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Open a remote file read stream (the fs seam's streamText). The returned
|
|
285
|
+
* stream must be consumed or destroyed; the pooled connection stays busy
|
|
286
|
+
* for the stream's lifetime.
|
|
287
|
+
*/
|
|
288
|
+
/**
|
|
289
|
+
* Open a remote file read stream (the fs seam's streamText). The returned
|
|
290
|
+
* stream must be consumed or destroyed; the pooled connection stays busy
|
|
291
|
+
* for the stream's lifetime (P0-10: a 'stream' lease, released on
|
|
292
|
+
* end/close/error/destroy — not when this function returns).
|
|
293
|
+
*/
|
|
294
|
+
async readStream(alias: string, remotePath: string, signal?: AbortSignal): Promise<import('node:stream').Readable> {
|
|
295
|
+
let lease: ClientLease | undefined
|
|
296
|
+
let lastError: unknown
|
|
297
|
+
|
|
298
|
+
// Preserve withClient's connect-only behavior: acquisition is safe to
|
|
299
|
+
// retry because no SFTP operation has started until a lease is obtained.
|
|
300
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
301
|
+
try {
|
|
302
|
+
lease = await this.access.acquire(alias, { kind: 'stream', signal })
|
|
303
|
+
break
|
|
304
|
+
} catch (error) {
|
|
305
|
+
lastError = error
|
|
306
|
+
if (signal?.aborted === true || attempt === 3) {
|
|
307
|
+
throw error instanceof Error ? error : new Error(String(error))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The loop either obtained a lease or threw on its final attempt.
|
|
313
|
+
if (lease === undefined) {
|
|
314
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const sftp = await this.sftpFor(lease.client)
|
|
319
|
+
const stream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
|
|
320
|
+
|
|
321
|
+
let released = false
|
|
322
|
+
let idleTimer: NodeJS.Timeout | undefined
|
|
323
|
+
const onAbort = (): void => {
|
|
324
|
+
const error = signal?.reason instanceof Error ? signal.reason : Object.assign(new Error('remote read stream aborted'), { name: 'AbortError' })
|
|
325
|
+
stream.destroy(error)
|
|
326
|
+
}
|
|
327
|
+
const armIdleTimeout = (): void => {
|
|
328
|
+
if (idleTimer !== undefined) clearTimeout(idleTimer)
|
|
329
|
+
idleTimer = setTimeout(() => {
|
|
330
|
+
stream.destroy(new Error(`remote read stream made no progress for ${this.sftpOpts.sftpReadTimeoutMs}ms: ${remotePath}`))
|
|
331
|
+
}, this.sftpOpts.sftpReadTimeoutMs)
|
|
332
|
+
idleTimer.unref?.()
|
|
333
|
+
}
|
|
334
|
+
const release = (): void => {
|
|
335
|
+
if (released) return
|
|
336
|
+
released = true
|
|
337
|
+
if (idleTimer !== undefined) clearTimeout(idleTimer)
|
|
338
|
+
signal?.removeEventListener('abort', onAbort)
|
|
339
|
+
// Drop the other terminal listeners so the lease closure is not
|
|
340
|
+
// retained after, e.g., 'end' fires before 'close'.
|
|
341
|
+
stream.removeListener('data', armIdleTimeout)
|
|
342
|
+
stream.removeListener('end', release)
|
|
343
|
+
stream.removeListener('close', release)
|
|
344
|
+
stream.removeListener('error', release)
|
|
345
|
+
lease.release()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
stream.on('data', armIdleTimeout)
|
|
349
|
+
stream.once('end', release)
|
|
350
|
+
stream.once('close', release)
|
|
351
|
+
stream.once('error', release)
|
|
352
|
+
|
|
353
|
+
// Node Readable.destroy() normally emits 'close', but ssh2's SFTP
|
|
354
|
+
// stream is outside our control — release synchronously as a fallback
|
|
355
|
+
// even if the implementation suppresses 'close'.
|
|
356
|
+
const originalDestroy = stream.destroy
|
|
357
|
+
stream.destroy = function destroy(error?: Error): typeof stream {
|
|
358
|
+
try {
|
|
359
|
+
return originalDestroy.call(this, error) as typeof stream
|
|
360
|
+
} finally {
|
|
361
|
+
release()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
365
|
+
if (signal?.aborted === true) onAbort()
|
|
366
|
+
else armIdleTimeout()
|
|
367
|
+
|
|
368
|
+
return stream
|
|
369
|
+
} catch (error) {
|
|
370
|
+
// Covers both caching/opening the SFTP subsystem and a synchronous
|
|
371
|
+
// createReadStream failure; no stream escaped, ownership ends here.
|
|
372
|
+
lease.release()
|
|
373
|
+
throw error
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Read one remote file fully into memory (text or binary) with its mtime.
|
|
379
|
+
* The workspace plugin's text gate (UTF-8 + size caps) lives on its caller.
|
|
380
|
+
*/
|
|
381
|
+
async readFile(alias: string, remotePath: string, maxBytes?: number, signal?: AbortSignal): Promise<{ content: Buffer; mtime: number; size: number }> {
|
|
382
|
+
const requestedLimit = maxBytes ?? this.sftpOpts.maxReadFileBytes
|
|
383
|
+
if (!Number.isSafeInteger(requestedLimit) || requestedLimit <= 0) throw new Error('readFile maxBytes must be a positive safe integer')
|
|
384
|
+
const limit = Math.min(requestedLimit, this.sftpOpts.maxReadFileBytes)
|
|
385
|
+
return this.access.withClient(alias, async (client) => {
|
|
386
|
+
const sftp = await this.sftpFor(client)
|
|
387
|
+
const attrs = await this.sftpStat(sftp, remotePath)
|
|
388
|
+
if (attrs.isDirectory()) throw new Error(`'${remotePath}' is a directory`)
|
|
389
|
+
if (attrs.size > limit) throw new Error(`remote file exceeds the ${limit}-byte read limit: ${remotePath}`)
|
|
390
|
+
const chunks: Buffer[] = []
|
|
391
|
+
let total = 0
|
|
392
|
+
let readStream: import('node:stream').Readable | undefined
|
|
393
|
+
await new Promise<void>((resolve, reject) => {
|
|
394
|
+
readStream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
|
|
395
|
+
let settled = false
|
|
396
|
+
let idleTimer: NodeJS.Timeout | undefined
|
|
397
|
+
const clearIdle = (): void => {
|
|
398
|
+
if (idleTimer !== undefined) clearTimeout(idleTimer)
|
|
399
|
+
}
|
|
400
|
+
const fail = (error: Error): void => {
|
|
401
|
+
if (settled) return
|
|
402
|
+
settled = true
|
|
403
|
+
clearIdle()
|
|
404
|
+
reject(error)
|
|
405
|
+
}
|
|
406
|
+
const armIdle = (): void => {
|
|
407
|
+
clearIdle()
|
|
408
|
+
idleTimer = setTimeout(() => {
|
|
409
|
+
if (settled) return
|
|
410
|
+
fail(new Error(`remote read made no progress for ${this.sftpOpts.sftpReadTimeoutMs}ms: ${remotePath}`))
|
|
411
|
+
try { readStream?.destroy() } catch { /* already closed */ }
|
|
412
|
+
}, this.sftpOpts.sftpReadTimeoutMs)
|
|
413
|
+
idleTimer.unref?.()
|
|
414
|
+
}
|
|
415
|
+
readStream.on('data', (chunk: Buffer) => {
|
|
416
|
+
if (settled) return
|
|
417
|
+
armIdle()
|
|
418
|
+
total += chunk.length
|
|
419
|
+
if (total > limit) {
|
|
420
|
+
fail(new Error(`remote file exceeded the ${limit}-byte read limit while streaming: ${remotePath}`))
|
|
421
|
+
try { readStream?.destroy() } catch { /* already closed */ }
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
chunks.push(chunk)
|
|
425
|
+
})
|
|
426
|
+
readStream.on('error', (error: Error) => { fail(error) })
|
|
427
|
+
readStream.on('end', () => {
|
|
428
|
+
if (settled) return
|
|
429
|
+
settled = true
|
|
430
|
+
clearIdle()
|
|
431
|
+
resolve()
|
|
432
|
+
})
|
|
433
|
+
armIdle()
|
|
434
|
+
})
|
|
435
|
+
return { content: Buffer.concat(chunks, total), mtime: attrs.mtime * 1000, size: attrs.size }
|
|
436
|
+
}, { signal })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Write one remote file from memory (parents are created). When
|
|
441
|
+
* `expectedMtime` is given, a stat-then-write conflict check throws before
|
|
442
|
+
* any byte is written (the GUI and the workspace tools use it for
|
|
443
|
+
* overwrite protection).
|
|
444
|
+
*/
|
|
445
|
+
async writeFile(alias: string, remotePath: string, content: Buffer, expectedMtime?: number, signal?: AbortSignal): Promise<{ mtime: number }> {
|
|
446
|
+
return this.access.withClient(alias, async (client) => {
|
|
447
|
+
const sftp = await this.sftpFor(client)
|
|
448
|
+
// Counted as an in-flight user of the shared subsystem: an upload that
|
|
449
|
+
// stalls must not have the channel rotated out from under it.
|
|
450
|
+
const entry = this.enter(sftp)
|
|
451
|
+
try {
|
|
452
|
+
await this.ensureRemoteDir(sftp, dirname(remotePath))
|
|
453
|
+
if (expectedMtime !== undefined) {
|
|
454
|
+
const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), this.sftpOpts.sftpOperationTimeoutMs, `remote write preflight stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
|
|
455
|
+
const current = attrs.mtime * 1000
|
|
456
|
+
if (current !== expectedMtime) {
|
|
457
|
+
throw new Error(`mtime conflict: remote mtime ${current} != expected ${expectedMtime}`)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
let writeStream: import('node:stream').Writable | undefined
|
|
461
|
+
await new Promise<void>((resolve, reject) => {
|
|
462
|
+
writeStream = sftp.createWriteStream(remotePath)
|
|
463
|
+
let settled = false
|
|
464
|
+
let idleTimer: NodeJS.Timeout | undefined
|
|
465
|
+
const clearIdle = (): void => {
|
|
466
|
+
if (idleTimer !== undefined) clearTimeout(idleTimer)
|
|
467
|
+
}
|
|
468
|
+
const fail = (error: Error): void => {
|
|
469
|
+
if (settled) return
|
|
470
|
+
settled = true
|
|
471
|
+
clearIdle()
|
|
472
|
+
reject(error)
|
|
473
|
+
}
|
|
474
|
+
const armIdle = (): void => {
|
|
475
|
+
clearIdle()
|
|
476
|
+
idleTimer = setTimeout(() => {
|
|
477
|
+
if (settled) return
|
|
478
|
+
const error = new Error(`remote write made no progress for ${this.sftpOpts.sftpReadTimeoutMs}ms: ${remotePath}`)
|
|
479
|
+
fail(error)
|
|
480
|
+
try { writeStream?.destroy(error) } catch { /* already closed */ }
|
|
481
|
+
this.retireSharedSubsystem(sftp)
|
|
482
|
+
}, this.sftpOpts.sftpReadTimeoutMs)
|
|
483
|
+
idleTimer.unref?.()
|
|
484
|
+
}
|
|
485
|
+
writeStream.on('error', (error: Error) => { fail(error) })
|
|
486
|
+
writeStream.on('close', () => {
|
|
487
|
+
if (settled) return
|
|
488
|
+
settled = true
|
|
489
|
+
clearIdle()
|
|
490
|
+
resolve()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
// Write sequential bounded chunks so each completed write is observable
|
|
494
|
+
// progress and refreshes the inactivity timer. There is deliberately no
|
|
495
|
+
// second absolute deadline: a slow transfer may continue while moving.
|
|
496
|
+
const writeNext = (offset: number): void => {
|
|
497
|
+
if (settled || writeStream === undefined) return
|
|
498
|
+
if (offset >= content.length) {
|
|
499
|
+
try { writeStream.end() } catch (error) { fail(error instanceof Error ? error : new Error(String(error))) }
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
const nextOffset = Math.min(offset + 64 * 1024, content.length)
|
|
503
|
+
try {
|
|
504
|
+
writeStream.write(content.subarray(offset, nextOffset), (error?: Error | null) => {
|
|
505
|
+
if (settled) return
|
|
506
|
+
if (error != null) {
|
|
507
|
+
fail(error)
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
armIdle()
|
|
511
|
+
queueMicrotask(() => { writeNext(nextOffset) })
|
|
512
|
+
})
|
|
513
|
+
} catch (error) {
|
|
514
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
armIdle()
|
|
518
|
+
writeNext(0)
|
|
519
|
+
})
|
|
520
|
+
const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), this.sftpOpts.sftpOperationTimeoutMs, `remote write result stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
|
|
521
|
+
return { mtime: attrs.mtime * 1000 }
|
|
522
|
+
} finally {
|
|
523
|
+
this.leave(entry)
|
|
524
|
+
}
|
|
525
|
+
}, { signal })
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Create a remote directory chain (mkdir -p semantics). */
|
|
529
|
+
async mkdir(alias: string, remotePath: string, signal?: AbortSignal): Promise<void> {
|
|
530
|
+
return this.access.withClient(alias, async (client) => {
|
|
531
|
+
const sftp = await this.sftpFor(client)
|
|
532
|
+
await this.ensureRemoteDir(sftp, remotePath)
|
|
533
|
+
}, { signal })
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Remove a remote file or directory. Directories require `recursive: true`
|
|
538
|
+
* and are walked depth-first (children first, then the directory itself).
|
|
539
|
+
*
|
|
540
|
+
* Deletion never follows symlinks: every node is classified with lstat, so
|
|
541
|
+
* a symlink pointing at a directory is unlinked (only the link), never
|
|
542
|
+
* recursed into — the old stat/readdir-attr check could delete the link
|
|
543
|
+
* target's contents.
|
|
544
|
+
*/
|
|
545
|
+
async rm(alias: string, remotePath: string, recursive = false, signal?: AbortSignal): Promise<void> {
|
|
546
|
+
const candidatePath = remotePath.replace(/\/+$/, '')
|
|
547
|
+
if (remotePath === '' || candidatePath === '' || candidatePath === '/') {
|
|
548
|
+
throw new Error(`refusing to delete root path '${remotePath}'`)
|
|
549
|
+
}
|
|
550
|
+
return this.access.withClient(alias, async (client) => {
|
|
551
|
+
const sftp = await this.sftpFor(client)
|
|
552
|
+
// A recursive rm runs many requests on the shared subsystem: count it so a
|
|
553
|
+
// timeout marks the channel suspect instead of tearing it down under other
|
|
554
|
+
// in-flight SFTP work.
|
|
555
|
+
const entry = this.enter(sftp)
|
|
556
|
+
try {
|
|
557
|
+
const deadlineAt = Date.now() + this.sftpOpts.sftpRecursiveRmTimeoutMs
|
|
558
|
+
const remaining = (): number => {
|
|
559
|
+
const value = deadlineAt - Date.now()
|
|
560
|
+
if (value <= 0) {
|
|
561
|
+
this.retireSharedSubsystem(sftp)
|
|
562
|
+
throw new Error(`remote recursive rm timed out after ${this.sftpOpts.sftpRecursiveRmTimeoutMs}ms: ${remotePath}`)
|
|
563
|
+
}
|
|
564
|
+
return Math.min(value, this.sftpOpts.sftpOperationTimeoutMs)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Inspect the leaf before canonicalizing: realpath follows a leaf
|
|
568
|
+
// symlink, which could turn `/link-to-dir` into its target and recurse
|
|
569
|
+
// through it. A leaf link is always unlinked by its original path.
|
|
570
|
+
const leafAttrs = await this.sftpLstat(sftp, candidatePath, recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs)
|
|
571
|
+
if (leafAttrs.isSymbolicLink()) {
|
|
572
|
+
await this.sftpUnlink(sftp, candidatePath, recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Canonicalize on this exact SFTP session before any destructive walk.
|
|
577
|
+
// This catches root-equivalent spellings such as `/.` and `/tmp/..`.
|
|
578
|
+
const realpathBudget = recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs
|
|
579
|
+
const canonical = await this.withSftpTimeout(sftp, new Promise<string>((resolve, reject) => {
|
|
580
|
+
sftp.realpath(candidatePath, (error, resolved) => error !== undefined ? reject(error) : resolve(resolved))
|
|
581
|
+
}), realpathBudget, `remote realpath timed out after ${realpathBudget}ms: ${candidatePath}`)
|
|
582
|
+
const canonicalPath = canonical.replace(/\/+$/, '') || '/'
|
|
583
|
+
if (canonicalPath === '/') {
|
|
584
|
+
throw new Error(`refusing to delete root-equivalent path '${remotePath}'`)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!leafAttrs.isDirectory()) {
|
|
588
|
+
await this.sftpUnlink(sftp, canonicalPath, recursive ? remaining() : this.sftpOpts.sftpOperationTimeoutMs)
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
if (!recursive) throw new Error(`'${remotePath}' is a directory —pass recursive: true`)
|
|
592
|
+
const remove = async (dir: string): Promise<void> => {
|
|
593
|
+
const readBudget = remaining()
|
|
594
|
+
const list = await this.withSftpTimeout(sftp, new Promise<Array<{ filename: string }>>((resolve, reject) => {
|
|
595
|
+
sftp.readdir(dir, (error, entries) => error !== undefined ? reject(error) : resolve(entries))
|
|
596
|
+
}), readBudget, `remote readdir timed out after ${readBudget}ms: ${dir}`)
|
|
597
|
+
for (const entry of list) {
|
|
598
|
+
const child = dir.replace(/\/+$/, '') + '/' + entry.filename
|
|
599
|
+
const childAttrs = await this.sftpLstat(sftp, child, remaining())
|
|
600
|
+
if (childAttrs.isSymbolicLink() || !childAttrs.isDirectory()) {
|
|
601
|
+
await this.sftpUnlink(sftp, child, remaining())
|
|
602
|
+
} else {
|
|
603
|
+
await remove(child)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const removeBudget = remaining()
|
|
607
|
+
await this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
|
|
608
|
+
sftp.rmdir(dir, (error) => error !== undefined ? reject(error) : resolve())
|
|
609
|
+
}), removeBudget, `remote rmdir timed out after ${removeBudget}ms: ${dir}`)
|
|
610
|
+
}
|
|
611
|
+
await remove(canonicalPath)
|
|
612
|
+
} finally {
|
|
613
|
+
this.leave(entry)
|
|
614
|
+
}
|
|
615
|
+
}, { signal })
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Rename / move a remote path (mv semantics, same filesystem). */
|
|
619
|
+
async rename(alias: string, fromPath: string, toPath: string, signal?: AbortSignal): Promise<void> {
|
|
620
|
+
return this.access.withClient(alias, async (client) => {
|
|
621
|
+
const sftp = await this.sftpFor(client)
|
|
622
|
+
await this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
|
|
623
|
+
sftp.rename(fromPath, toPath, (error) => error !== undefined ? reject(error) : resolve())
|
|
624
|
+
}), this.sftpOpts.sftpOperationTimeoutMs, `remote rename timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${fromPath} -> ${toPath}`)
|
|
625
|
+
}, { signal })
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Reject a promise after `ms` (unref'd so it never keeps the process alive).
|
|
629
|
+
* `onTimeout` (when given) runs right before the rejection: ssh2 SFTP
|
|
630
|
+
* requests have no cancel API, so callers that hold an abort handle (e.g.
|
|
631
|
+
* a read stream) destroy it here — otherwise the underlying transfer would
|
|
632
|
+
* keep running (and, for reads, keep buffering) after the caller was told
|
|
633
|
+
* it timed out. */
|
|
634
|
+
withTimeout<T>(promise: Promise<T>, ms: number, message: string, onTimeout?: () => void): Promise<T> {
|
|
635
|
+
return new Promise<T>((resolve, reject) => {
|
|
636
|
+
let settled = false
|
|
637
|
+
const timer = setTimeout(() => {
|
|
638
|
+
if (settled) return
|
|
639
|
+
settled = true
|
|
640
|
+
try { onTimeout?.() } catch { /* best-effort abort */ }
|
|
641
|
+
reject(new Error(message))
|
|
642
|
+
}, ms)
|
|
643
|
+
timer.unref?.()
|
|
644
|
+
promise.then(
|
|
645
|
+
(value) => {
|
|
646
|
+
if (settled) return
|
|
647
|
+
settled = true
|
|
648
|
+
clearTimeout(timer)
|
|
649
|
+
resolve(value)
|
|
650
|
+
},
|
|
651
|
+
(error) => {
|
|
652
|
+
if (settled) return
|
|
653
|
+
settled = true
|
|
654
|
+
clearTimeout(timer)
|
|
655
|
+
reject(error)
|
|
656
|
+
},
|
|
657
|
+
)
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Bound one SFTP request and retire the subsystem only when it is safe.
|
|
662
|
+
*
|
|
663
|
+
* Every request rides ONE cached subsystem channel per pooled client. Closing
|
|
664
|
+
* that channel on a single request's timeout used to destroy every concurrent
|
|
665
|
+
* SFTP operation on the same connection. A timeout therefore only MARKS the
|
|
666
|
+
* channel suspect; the decision to close it belongs to the drain path, which
|
|
667
|
+
* ends it exactly once the last in-flight request has settled — so a stalled
|
|
668
|
+
* callback cannot linger behind another lease, and a healthy concurrent request
|
|
669
|
+
* is never destroyed. */
|
|
670
|
+
withSftpTimeout<T>(
|
|
671
|
+
sftp: import('ssh2').SFTPWrapper,
|
|
672
|
+
promise: Promise<T>,
|
|
673
|
+
ms: number,
|
|
674
|
+
message: string,
|
|
675
|
+
onTimeout?: () => void,
|
|
676
|
+
): Promise<T> {
|
|
677
|
+
const entry = this.enter(sftp)
|
|
678
|
+
return this.withTimeout(promise, ms, message, () => {
|
|
679
|
+
try { onTimeout?.() } catch { /* best-effort operation abort */ }
|
|
680
|
+
this.retireSharedSubsystem(sftp)
|
|
681
|
+
}).finally(() => { this.leave(entry) })
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Count one operation against a cached subsystem's in-flight set. */
|
|
685
|
+
private enter(sftp: import('ssh2').SFTPWrapper): SftpCacheEntry | undefined {
|
|
686
|
+
const entry = this.entryFor(sftp)
|
|
687
|
+
if (entry !== undefined) entry.inFlight += 1
|
|
688
|
+
return entry
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** End one operation's count; rotate a suspect channel only once it drains. */
|
|
692
|
+
private leave(entry: SftpCacheEntry | undefined): void {
|
|
693
|
+
if (entry === undefined) return
|
|
694
|
+
entry.inFlight = Math.max(0, entry.inFlight - 1)
|
|
695
|
+
if (entry.inFlight === 0 && entry.suspect) this.rotateSuspect(entry)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Retire a subsystem after a timeout without destroying concurrent SFTP work.
|
|
700
|
+
*
|
|
701
|
+
* A cached channel is SHARED by every operation on the pooled client, so
|
|
702
|
+
* closing it here would fail whatever else is in flight. Instead it is marked
|
|
703
|
+
* suspect and rotated by `leave()` once the last operation finishes. Only a
|
|
704
|
+
* wrapper this service does not track (a direct caller) is ended immediately.
|
|
705
|
+
*
|
|
706
|
+
* @param sftp - the subsystem that timed out.
|
|
707
|
+
*/
|
|
708
|
+
private retireSharedSubsystem(sftp: import('ssh2').SFTPWrapper): void {
|
|
709
|
+
const entry = this.entryFor(sftp)
|
|
710
|
+
if (entry === undefined) {
|
|
711
|
+
try { sftp.end() } catch { /* channel already closed */ }
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
entry.suspect = true
|
|
715
|
+
if (entry.inFlight === 0) this.rotateSuspect(entry)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** The cache entry owning one resolved subsystem channel, if still cached. */
|
|
719
|
+
private entryFor(sftp: import('ssh2').SFTPWrapper): SftpCacheEntry | undefined {
|
|
720
|
+
for (const entry of this.cache.values()) {
|
|
721
|
+
if (entry.wrapper === sftp) return entry
|
|
722
|
+
}
|
|
723
|
+
return undefined
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Drop a suspect channel once it has drained, so the next call reopens. */
|
|
727
|
+
private rotateSuspect(entry: SftpCacheEntry): void {
|
|
728
|
+
if (this.cache.get(entry.client) === entry) this.cache.delete(entry.client)
|
|
729
|
+
try { entry.wrapper?.end() } catch { /* already closed */ }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Stat wrapper (one SFTP stat call). */
|
|
733
|
+
sftpStat(sftp: import('ssh2').SFTPWrapper, remotePath: string): Promise<import('ssh2').Stats> {
|
|
734
|
+
return this.withSftpTimeout(sftp, new Promise((resolve, reject) => {
|
|
735
|
+
sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
736
|
+
}), this.sftpOpts.sftpOperationTimeoutMs, `remote stat timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${remotePath}`)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Lstat wrapper (does NOT follow symlinks — the deletion safety gate). */
|
|
740
|
+
sftpLstat(sftp: import('ssh2').SFTPWrapper, remotePath: string, timeoutMs = this.sftpOpts.sftpOperationTimeoutMs): Promise<import('ssh2').Stats> {
|
|
741
|
+
return this.withSftpTimeout(sftp, new Promise((resolve, reject) => {
|
|
742
|
+
sftp.lstat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
743
|
+
}), timeoutMs, `remote lstat timed out after ${timeoutMs}ms: ${remotePath}`)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Unlink wrapper with the same per-operation deadline as every other SFTP callback. */
|
|
747
|
+
sftpUnlink(sftp: import('ssh2').SFTPWrapper, remotePath: string, timeoutMs = this.sftpOpts.sftpOperationTimeoutMs): Promise<void> {
|
|
748
|
+
return this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
|
|
749
|
+
sftp.unlink(remotePath, (error) => error !== undefined ? reject(error) : resolve())
|
|
750
|
+
}), timeoutMs, `remote unlink timed out after ${timeoutMs}ms: ${remotePath}`)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The (cached) SFTP channel for a pooled client. `Client.sftp()` opens a new
|
|
755
|
+
* subsystem channel per call, so this memoizes one channel per live client;
|
|
756
|
+
* when the channel closes the cache entry is dropped so the next call opens
|
|
757
|
+
* SFTP on the replacement connection. Failed opens are also evicted so a
|
|
758
|
+
* transient channel failure can be retried.
|
|
759
|
+
*/
|
|
760
|
+
sftpFor(client: Client): Promise<import('ssh2').SFTPWrapper> {
|
|
761
|
+
const cached = this.cache.get(client)
|
|
762
|
+
if (cached !== undefined) return cached.promise
|
|
763
|
+
|
|
764
|
+
let resolveOpen!: (sftp: import('ssh2').SFTPWrapper) => void
|
|
765
|
+
let rejectOpen!: (error: Error) => void
|
|
766
|
+
let openSettled = false
|
|
767
|
+
let disposed = false
|
|
768
|
+
let timer: NodeJS.Timeout | undefined
|
|
769
|
+
const promise = new Promise<import('ssh2').SFTPWrapper>((resolve, reject) => {
|
|
770
|
+
resolveOpen = resolve
|
|
771
|
+
rejectOpen = reject
|
|
772
|
+
})
|
|
773
|
+
const entry: SftpCacheEntry = {
|
|
774
|
+
promise,
|
|
775
|
+
client,
|
|
776
|
+
inFlight: 0,
|
|
777
|
+
suspect: false,
|
|
778
|
+
cancel: (error) => {
|
|
779
|
+
if (disposed) return
|
|
780
|
+
disposed = true
|
|
781
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
782
|
+
if (this.cache.get(client) === entry) this.cache.delete(client)
|
|
783
|
+
if (entry.wrapper !== undefined) {
|
|
784
|
+
try { entry.wrapper.end() } catch { /* already closed */ }
|
|
785
|
+
} else {
|
|
786
|
+
try { client.destroy() } catch { /* already closed */ }
|
|
787
|
+
}
|
|
788
|
+
if (!openSettled) {
|
|
789
|
+
openSettled = true
|
|
790
|
+
rejectOpen(error)
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
}
|
|
794
|
+
this.cache.set(client, entry)
|
|
795
|
+
timer = setTimeout(() => {
|
|
796
|
+
entry.cancel(new Error(`SFTP subsystem open timed out after ${this.sftpOpts.sftpOpenTimeoutMs}ms`))
|
|
797
|
+
}, this.sftpOpts.sftpOpenTimeoutMs)
|
|
798
|
+
timer.unref?.()
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
client.sftp((error, sftp) => {
|
|
802
|
+
if (disposed || this.cache.get(client) !== entry) {
|
|
803
|
+
if (sftp !== undefined) {
|
|
804
|
+
try { sftp.end() } catch { /* stale late callback */ }
|
|
805
|
+
}
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
if (error !== undefined) {
|
|
809
|
+
entry.cancel(error instanceof Error ? error : new Error(String(error)))
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
openSettled = true
|
|
813
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
814
|
+
entry.wrapper = sftp
|
|
815
|
+
const evict = (): void => {
|
|
816
|
+
if (this.cache.get(client) === entry) this.cache.delete(client)
|
|
817
|
+
}
|
|
818
|
+
sftp.once('close', evict)
|
|
819
|
+
sftp.once('error', evict)
|
|
820
|
+
resolveOpen(sftp)
|
|
821
|
+
})
|
|
822
|
+
} catch (error) {
|
|
823
|
+
entry.cancel(error instanceof Error ? error : new Error(String(error)))
|
|
824
|
+
}
|
|
825
|
+
return promise
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
829
|
+
async ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void> {
|
|
830
|
+
const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
|
|
831
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
832
|
+
const current = '/' + segments.slice(0, index + 1).join('/')
|
|
833
|
+
try {
|
|
834
|
+
await this.sftpStat(sftp, current)
|
|
835
|
+
continue
|
|
836
|
+
} catch (error) {
|
|
837
|
+
// Only absence authorizes mkdir. Permission, timeout and transport
|
|
838
|
+
// failures must not be converted into an unintended remote mutation.
|
|
839
|
+
if (!isMissingSftpError(error)) throw error
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
await this.withSftpTimeout(sftp, new Promise<void>((resolve, reject) => {
|
|
843
|
+
sftp.mkdir(current, (error) => error !== undefined ? reject(error) : resolve())
|
|
844
|
+
}), this.sftpOpts.sftpOperationTimeoutMs, `remote mkdir timed out after ${this.sftpOpts.sftpOperationTimeoutMs}ms: ${current}`)
|
|
845
|
+
} catch (error) {
|
|
846
|
+
const code = String((error as { code?: unknown } | undefined)?.code ?? '')
|
|
847
|
+
if (!/EEXIST|already exists/i.test(`${code} ${String(error)}`)) throw error
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
853
|
+
const entry = this.enter(sftp)
|
|
854
|
+
return new Promise<void>((resolve, reject) => {
|
|
855
|
+
const tracker = createTransferProgressTracker(dst, statSync(src).size, onProgress)
|
|
856
|
+
let settled = false
|
|
857
|
+
let timer: NodeJS.Timeout | undefined
|
|
858
|
+
const arm = (): void => {
|
|
859
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
860
|
+
timer = setTimeout(() => {
|
|
861
|
+
if (settled) return
|
|
862
|
+
settled = true
|
|
863
|
+
const error = new Error(`remote upload made no progress for ${this.sftpOpts.sftpTransferIdleTimeoutMs}ms: ${dst}`)
|
|
864
|
+
tracker.fail(error)
|
|
865
|
+
this.retireSharedSubsystem(sftp)
|
|
866
|
+
reject(error)
|
|
867
|
+
}, this.sftpOpts.sftpTransferIdleTimeoutMs)
|
|
868
|
+
timer.unref?.()
|
|
869
|
+
}
|
|
870
|
+
arm()
|
|
871
|
+
try {
|
|
872
|
+
sftp.fastPut(src, dst, {
|
|
873
|
+
concurrency: this.sftpOpts.sftpConcurrency,
|
|
874
|
+
step: (transferred: number, _chunk: number, total: number) => {
|
|
875
|
+
if (settled) return
|
|
876
|
+
arm()
|
|
877
|
+
tracker.step(transferred, total)
|
|
878
|
+
},
|
|
879
|
+
}, (error) => {
|
|
880
|
+
if (settled) return
|
|
881
|
+
settled = true
|
|
882
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
883
|
+
if (error !== undefined) {
|
|
884
|
+
tracker.fail(error)
|
|
885
|
+
reject(error)
|
|
886
|
+
} else {
|
|
887
|
+
tracker.done()
|
|
888
|
+
resolve()
|
|
889
|
+
}
|
|
890
|
+
})
|
|
891
|
+
} catch (error) {
|
|
892
|
+
settled = true
|
|
893
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
894
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
895
|
+
tracker.fail(failure)
|
|
896
|
+
reject(failure)
|
|
897
|
+
}
|
|
898
|
+
}).finally(() => { this.leave(entry) })
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, initialTotal: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
902
|
+
const entry = this.enter(sftp)
|
|
903
|
+
return new Promise<void>((resolve, reject) => {
|
|
904
|
+
const tracker = createTransferProgressTracker(src, initialTotal, onProgress)
|
|
905
|
+
const removePartial = (): void => { try { unlinkSync(dst) } catch { /* absent or already removed */ } }
|
|
906
|
+
let settled = false
|
|
907
|
+
let timer: NodeJS.Timeout | undefined
|
|
908
|
+
const arm = (): void => {
|
|
909
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
910
|
+
timer = setTimeout(() => {
|
|
911
|
+
if (settled) return
|
|
912
|
+
settled = true
|
|
913
|
+
const error = new Error(`remote download made no progress for ${this.sftpOpts.sftpTransferIdleTimeoutMs}ms: ${src}`)
|
|
914
|
+
tracker.fail(error)
|
|
915
|
+
removePartial()
|
|
916
|
+
this.retireSharedSubsystem(sftp)
|
|
917
|
+
reject(error)
|
|
918
|
+
}, this.sftpOpts.sftpTransferIdleTimeoutMs)
|
|
919
|
+
timer.unref?.()
|
|
920
|
+
}
|
|
921
|
+
arm()
|
|
922
|
+
try {
|
|
923
|
+
sftp.fastGet(src, dst, {
|
|
924
|
+
concurrency: this.sftpOpts.sftpConcurrency,
|
|
925
|
+
step: (transferred: number, _chunk: number, total: number) => {
|
|
926
|
+
if (settled) return
|
|
927
|
+
arm()
|
|
928
|
+
tracker.step(transferred, total)
|
|
929
|
+
},
|
|
930
|
+
}, (error) => {
|
|
931
|
+
if (settled) return
|
|
932
|
+
settled = true
|
|
933
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
934
|
+
if (error !== undefined) {
|
|
935
|
+
tracker.fail(error)
|
|
936
|
+
removePartial()
|
|
937
|
+
reject(error)
|
|
938
|
+
} else {
|
|
939
|
+
tracker.done()
|
|
940
|
+
resolve()
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
} catch (error) {
|
|
944
|
+
settled = true
|
|
945
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
946
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
947
|
+
tracker.fail(failure)
|
|
948
|
+
removePartial()
|
|
949
|
+
reject(failure)
|
|
950
|
+
}
|
|
951
|
+
}).finally(() => { this.leave(entry) })
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Drop one client's cached channel. Wired as the connection pool's
|
|
956
|
+
* onDispose hook, so a torn-down client never keeps a half-open SFTP
|
|
957
|
+
* subsystem behind it.
|
|
958
|
+
*/
|
|
959
|
+
onClientDisposed(client: Client, error: Error): void {
|
|
960
|
+
this.cache.get(client)?.cancel(error)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Drop every cached channel (engine dispose). */
|
|
964
|
+
dispose(error: Error): void {
|
|
965
|
+
for (const entry of [...this.cache.values()]) entry.cancel(error)
|
|
966
|
+
}
|
|
967
|
+
}
|