dsh-winrm 0.2.0 → 0.3.0

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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Raw WSMan `Send` for the streaming upload path.
3
+ *
4
+ * The bundled `winrm-client` can only put text on the WinRS stdin stream: its
5
+ * `doSendInput` base64-encodes the input *again* on top of whatever the caller
6
+ * already encoded, and it has no way to close the stream. Both are fatal for a
7
+ * bulk transfer — double base64 costs 1.78x the bytes on the wire, and a
8
+ * stdin stream that never ends cannot tell the remote receiver "that is all".
9
+ *
10
+ * This module therefore builds the `Send` envelope itself and posts it through
11
+ * the library's own `sendHttp`, which keeps Basic/NTLM/SPNEGO and the HTTPS
12
+ * options exactly as the rest of the transport uses them. The stream carries
13
+ * raw file bytes, so the wire cost is 1.0x plus the envelope.
14
+ *
15
+ * The library is reached through `createRequire` rather than a static import
16
+ * because `dist/src/utils/http.js` is an internal CommonJS path with no
17
+ * `exports` map behind it. `loadSendHttp` returns null when that path is gone
18
+ * (a future `winrm-client` layout change), which lets the caller fall back to
19
+ * the old chunked path instead of failing the transfer.
20
+ */
21
+
22
+ import { randomUUID } from 'node:crypto'
23
+ import { createRequire } from 'node:module'
24
+
25
+ /** Connection parameters for one target (mirrors `WinRMParams` in client.ts). */
26
+ export interface WsmanTarget {
27
+ host: string
28
+ port: number
29
+ path: string
30
+ username: string
31
+ password: string
32
+ authMethod: 'basic' | 'ntlm'
33
+ useHttps?: boolean
34
+ rejectUnauthorized?: boolean
35
+ }
36
+
37
+ /** `sendHttp` as the bundled library declares it. */
38
+ type SendHttp = (
39
+ data: string,
40
+ host: string,
41
+ port: number,
42
+ path: string,
43
+ username: string,
44
+ password: string,
45
+ authMethod: 'basic' | 'ntlm',
46
+ timeout?: number,
47
+ useHttps?: boolean,
48
+ rejectUnauthorized?: boolean,
49
+ ) => Promise<unknown>
50
+
51
+ /**
52
+ * Envelope ceiling declared on every Send, in bytes.
53
+ *
54
+ * This is what the *client* promises the server it will never exceed, and the
55
+ * server enforces its own `MaxEnvelopeSizekb` on top. 512000 is the value the
56
+ * target host reports, and it is far above the library's hardcoded 153600 —
57
+ * raising it is what makes 256 KiB chunks legal.
58
+ */
59
+ export const WS_MAX_ENVELOPE_BYTES = 512_000
60
+
61
+ /**
62
+ * Bytes of XML wrapper around one base64 chunk.
63
+ *
64
+ * Measured against the envelope this module emits: fixed header, selector set,
65
+ * body tags and the message id. Used to size chunks against the ceiling before
66
+ * sending, so a normal transfer never needs a size rejection to converge.
67
+ */
68
+ export const SEND_ENVELOPE_OVERHEAD_BYTES = 1_600
69
+
70
+ /** The largest raw slice whose base64 form still fits `WS_MAX_ENVELOPE_BYTES`. */
71
+ export const MAX_SEND_SLICE_BYTES = Math.floor(
72
+ ((WS_MAX_ENVELOPE_BYTES - SEND_ENVELOPE_OVERHEAD_BYTES) / 4) * 3,
73
+ )
74
+
75
+ let sendHttpCache: SendHttp | null | undefined
76
+
77
+ /**
78
+ * Resolve the library's `sendHttp`, or null when the internal path is absent.
79
+ * @returns the send function, or null when it cannot be loaded.
80
+ */
81
+ export function loadSendHttp(): SendHttp | null {
82
+ if (sendHttpCache !== undefined) return sendHttpCache
83
+ try {
84
+ const require = createRequire(import.meta.url)
85
+ const mod = require('winrm-client/dist/src/utils/http.js') as { sendHttp?: unknown }
86
+ sendHttpCache = typeof mod.sendHttp === 'function' ? (mod.sendHttp as SendHttp) : null
87
+ } catch {
88
+ sendHttpCache = null
89
+ }
90
+ return sendHttpCache
91
+ }
92
+
93
+ /**
94
+ * Build one WSMan `Send` envelope carrying `chunk` on the stdin stream.
95
+ *
96
+ * The header mirrors what the library emits, with three deliberate changes:
97
+ * `MaxEnvelopeSize` is raised to `limit`, the `Send` action replaces the
98
+ * command's, and the stream body is the caller's base64 verbatim — no second
99
+ * encode. `Name="stdin"` and `CommandId` are what bind the bytes to the
100
+ * running receiver's standard input.
101
+ * @param shellId - target shell from `Shell.doCreateShell`.
102
+ * @param commandId - target command from `Command.doExecuteCommand`.
103
+ * @param chunk - raw bytes to place on the remote stdin stream.
104
+ * @param limit - envelope ceiling to declare.
105
+ * @returns the SOAP envelope as a UTF-8 string.
106
+ */
107
+ export function buildSendEnvelope(shellId: string, commandId: string, chunk: Buffer, limit: number): string {
108
+ return (
109
+ '<?xml version="1.0" encoding="UTF-8"?>' +
110
+ '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"' +
111
+ ' xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"' +
112
+ ' xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"' +
113
+ ' xmlns:p="http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd"' +
114
+ ' xmlns:rsp="http://schemas.microsoft.com/wbem/wsman/1/windows/shell">' +
115
+ '<s:Header>' +
116
+ '<wsa:To>http://windows-host:5985/wsman</wsa:To>' +
117
+ '<wsman:ResourceURI mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI>' +
118
+ '<wsa:ReplyTo><wsa:Address mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo>' +
119
+ '<wsman:MaxEnvelopeSize mustUnderstand="true">' +
120
+ String(limit) +
121
+ '</wsman:MaxEnvelopeSize>' +
122
+ '<wsa:MessageID>uuid:' +
123
+ randomUUID() +
124
+ '</wsa:MessageID>' +
125
+ '<wsman:Locale mustUnderstand="false" xml:lang="en-US"/>' +
126
+ '<wsman:OperationTimeout>PT60S</wsman:OperationTimeout>' +
127
+ '<wsa:Action mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Send</wsa:Action>' +
128
+ '<wsman:SelectorSet><wsman:Selector Name="ShellId">' +
129
+ shellId +
130
+ '</wsman:Selector></wsman:SelectorSet>' +
131
+ '</s:Header>' +
132
+ '<s:Body><rsp:Send><rsp:Stream CommandId="' +
133
+ commandId +
134
+ '" Name="stdin">' +
135
+ chunk.toString('base64') +
136
+ '</rsp:Stream></rsp:Send></s:Body>' +
137
+ '</s:Envelope>'
138
+ )
139
+ }
140
+
141
+ /**
142
+ * Push one raw slice onto the remote stdin stream.
143
+ *
144
+ * A rejection here is safe to retry with a smaller slice: the server validates
145
+ * the envelope size before dispatching it to the shell, so nothing reached the
146
+ * receiver's stdin when the call fails on size.
147
+ * @param target - connection parameters (auth already selected).
148
+ * @param shellId - target shell.
149
+ * @param commandId - target command.
150
+ * @param chunk - raw bytes to send.
151
+ * @param sendHttp - the resolved library function.
152
+ * @param timeoutMs - per-request HTTP timeout.
153
+ */
154
+ export async function sendStdinChunk(
155
+ target: WsmanTarget,
156
+ shellId: string,
157
+ commandId: string,
158
+ chunk: Buffer,
159
+ sendHttp: SendHttp,
160
+ timeoutMs: number,
161
+ ): Promise<void> {
162
+ const envelope = buildSendEnvelope(shellId, commandId, chunk, WS_MAX_ENVELOPE_BYTES)
163
+ await sendHttp(
164
+ envelope,
165
+ target.host,
166
+ target.port,
167
+ target.path,
168
+ target.username,
169
+ target.password,
170
+ target.authMethod,
171
+ timeoutMs,
172
+ target.useHttps,
173
+ target.rejectUnauthorized,
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Whether a failed Send was refused for exceeding the envelope ceiling.
179
+ *
180
+ * WSMan reports this as a SOAP fault rather than a status code, and the exact
181
+ * wording differs between the HTTP and HTTPS stacks, so the test looks for any
182
+ * of the size-related phrasings plus the plain 413 status. It deliberately
183
+ * does not match a bare "size" in unrelated text.
184
+ * @param error - the rejection to classify.
185
+ * @returns true when retrying with a smaller slice is the right response.
186
+ */
187
+ export function isEnvelopeTooLarge(error: unknown): boolean {
188
+ const message = error instanceof Error ? error.message : String(error)
189
+ return (
190
+ /\b413\b/.test(message) ||
191
+ /envelope/i.test(message) ||
192
+ /MaxEnvelopeSize/i.test(message) ||
193
+ /maximum.*(size|length)/i.test(message)
194
+ )
195
+ }
196
+
197
+ /**
198
+ * Build one WSMan `Receive` envelope asking for stdout and stderr.
199
+ *
200
+ * The library's own `buildReceiveOutputRequest` cannot be reused here because
201
+ * it hardcodes `MaxEnvelopeSize` at 153600 in its shared header. Measured
202
+ * against this target, that ceiling is what caps each response at 128 KiB —
203
+ * raising it to 512000 lets one `Receive` return 128 KiB per stream, which is
204
+ * the most the server will put in a single response regardless.
205
+ * @param shellId - target shell.
206
+ * @param commandId - target command whose streams are wanted.
207
+ * @param limit - envelope ceiling to declare.
208
+ * @returns the SOAP envelope as a UTF-8 string.
209
+ */
210
+ export function buildReceiveEnvelope(shellId: string, commandId: string, limit: number): string {
211
+ return (
212
+ '<?xml version="1.0" encoding="UTF-8"?>' +
213
+ '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"' +
214
+ ' xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"' +
215
+ ' xmlns:wsman="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"' +
216
+ ' xmlns:rsp="http://schemas.microsoft.com/wbem/wsman/1/windows/shell">' +
217
+ '<s:Header>' +
218
+ '<wsa:To>http://windows-host:5985/wsman</wsa:To>' +
219
+ '<wsman:ResourceURI mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI>' +
220
+ '<wsa:ReplyTo><wsa:Address mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo>' +
221
+ '<wsman:MaxEnvelopeSize mustUnderstand="true">' +
222
+ String(limit) +
223
+ '</wsman:MaxEnvelopeSize>' +
224
+ '<wsa:MessageID>uuid:' +
225
+ randomUUID() +
226
+ '</wsa:MessageID>' +
227
+ '<wsman:Locale mustUnderstand="false" xml:lang="en-US"/>' +
228
+ '<wsman:OperationTimeout>PT60S</wsman:OperationTimeout>' +
229
+ '<wsa:Action mustUnderstand="true">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive</wsa:Action>' +
230
+ '<wsman:SelectorSet><wsman:Selector Name="ShellId">' +
231
+ shellId +
232
+ '</wsman:Selector></wsman:SelectorSet>' +
233
+ '</s:Header>' +
234
+ '<s:Body><rsp:Receive><rsp:DesiredStream CommandId="' +
235
+ commandId +
236
+ '">stdout stderr</rsp:DesiredStream></rsp:Receive></s:Body>' +
237
+ '</s:Envelope>'
238
+ )
239
+ }
240
+
241
+ /** One decoded `rsp:Stream` element from a Receive response. */
242
+ export interface ReceivedStream {
243
+ /** `Name` attribute: `stdout` or `stderr`. */
244
+ name: string
245
+ /** Decoded payload bytes; empty for a stream element that carried no text. */
246
+ data: Buffer
247
+ /** True when the `End` attribute marks this stream closed. */
248
+ end: boolean
249
+ }
250
+
251
+ /** A decoded Receive response: its streams plus the command's exit code once known. */
252
+ export interface ReceiveResult {
253
+ streams: ReceivedStream[]
254
+ /** `rsp:CommandState.rsp:ExitCode`, or undefined while the command still runs. */
255
+ exitCode?: number
256
+ }
257
+
258
+ /**
259
+ * Decode a base64 text node that the XML parser may have coerced to a number.
260
+ *
261
+ * fast-xml-parser runs with `parseTagValue` enabled, so an all-digit text node
262
+ * comes back as a JS number: `"0123"` parses to `123`, `"0000"` to `0`, and
263
+ * `"1e55"` to a float. A base64 payload is usually long enough to contain a
264
+ * non-digit and stay a string, but the final short chunk of a stream is exactly
265
+ * where a leading zero can appear, and silently reinterpreting it would corrupt
266
+ * the file. Converting through `String()` recovers the digits for every value
267
+ * that lost only leading zeros; a value that came back in exponent form cannot
268
+ * be recovered and is rejected rather than guessed at.
269
+ * @param text - the raw text node as the parser produced it.
270
+ * @returns the base64 text, or null when it cannot be reconstructed.
271
+ */
272
+ function coerceBase64(text: unknown): string | null {
273
+ if (typeof text === 'string') return text.replace(/\s+/g, '')
274
+ if (typeof text === 'number' && Number.isFinite(text) && Number.isInteger(text) && text >= 0) {
275
+ return String(text)
276
+ }
277
+ return null
278
+ }
279
+
280
+ /**
281
+ * Read the streams and exit code out of a Receive response.
282
+ *
283
+ * The library's `extractStreams` cannot be used for binary payloads: it decodes
284
+ * every stream through `extractText`, which falls back to `String(obj)` for an
285
+ * element that has attributes but no text node — turning the terminal
286
+ * `End="true"` marker into the literal string `'[object Object]'` — and the
287
+ * library's `doReceiveOutputNonBlocking` then `.trim()`s the decoded text,
288
+ * which would eat whitespace bytes at either end of a real payload. This
289
+ * reader keeps the distinction between "no text" (zero bytes) and "text" and
290
+ * never trims.
291
+ * @param response - the parsed XML object returned by `sendHttp`.
292
+ * @returns the decoded streams and exit code.
293
+ * @throws when a stream's text node cannot be decoded as base64.
294
+ */
295
+ export function readReceiveResult(response: unknown): ReceiveResult {
296
+ const envelope = response as Record<string, unknown> | null
297
+ const body = (envelope?.['s:Envelope'] as Record<string, unknown> | undefined)?.['s:Body'] as
298
+ | Record<string, unknown>
299
+ | undefined
300
+ const receive = body?.['rsp:ReceiveResponse'] as Record<string, unknown> | undefined
301
+ const streams: ReceivedStream[] = []
302
+ if (receive !== undefined) {
303
+ const raw = receive['rsp:Stream']
304
+ const list = raw === undefined || raw === null ? [] : Array.isArray(raw) ? raw : [raw]
305
+ for (const element of list) {
306
+ const node = element as Record<string, unknown>
307
+ const text = node !== null && typeof node === 'object' && '_' in node ? node['_'] : ''
308
+ const attributes = (node?.['$'] ?? {}) as Record<string, unknown>
309
+ const name = String(attributes['@_Name'] ?? '')
310
+ const end = String(attributes['@_End'] ?? '') === 'true'
311
+ if (text === '' || text === null || text === undefined) {
312
+ streams.push({ name, data: Buffer.alloc(0), end })
313
+ continue
314
+ }
315
+ const base64 = coerceBase64(text)
316
+ if (base64 === null) {
317
+ throw new Error('receive stream returned an undecodable text node: ' + String(text))
318
+ }
319
+ streams.push({ name, data: base64 === '' ? Buffer.alloc(0) : Buffer.from(base64, 'base64'), end })
320
+ }
321
+ }
322
+ const state = receive?.['rsp:CommandState'] as Record<string, unknown> | undefined
323
+ const rawExit = state?.['rsp:ExitCode']
324
+ const exitCode = rawExit === undefined || rawExit === null ? undefined : Number(rawExit)
325
+ return { streams, exitCode: exitCode !== undefined && Number.isFinite(exitCode) ? exitCode : undefined }
326
+ }
327
+
328
+ /**
329
+ * Fetch one Receive response for a running command.
330
+ * @param target - connection parameters (auth already selected).
331
+ * @param shellId - target shell.
332
+ * @param commandId - target command.
333
+ * @param sendHttp - the resolved library function.
334
+ * @param timeoutMs - per-request HTTP timeout.
335
+ * @returns the decoded streams and exit code.
336
+ */
337
+ export async function receiveOutput(
338
+ target: WsmanTarget,
339
+ shellId: string,
340
+ commandId: string,
341
+ sendHttp: SendHttp,
342
+ timeoutMs: number,
343
+ ): Promise<ReceiveResult> {
344
+ const response = await sendHttp(
345
+ buildReceiveEnvelope(shellId, commandId, WS_MAX_ENVELOPE_BYTES),
346
+ target.host,
347
+ target.port,
348
+ target.path,
349
+ target.username,
350
+ target.password,
351
+ target.authMethod,
352
+ timeoutMs,
353
+ target.useHttps,
354
+ target.rejectUnauthorized,
355
+ )
356
+ return readReceiveResult(response)
357
+ }
package/src/engine.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * The WinRM engine facade: host lookup, PowerShell exec (UTF-8 envelope),
3
- * service & process management, directory listing, base64-chunked file
4
- * transfer, cluster execution and the streaming console. The heavy lifting
5
- * lives in engine/ (client, console); this class composes them behind one
6
- * WinRmEngine instance per plugin apply.
3
+ * service & process management, directory listing, file transfer, cluster
4
+ * execution and the streaming console. The heavy lifting lives in engine/
5
+ * (client, console); this class composes them behind one WinRmEngine instance
6
+ * per plugin apply.
7
7
  */
8
8
 
9
9
  import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts'
10
10
  import type { HostStore } from './store.ts'
11
- import { connOf, downloadChunks, runScript, testConnection, uploadBuffer } from './engine/client.ts'
12
- import { downloadSmb, uploadSmb, type TransferChannel } from './smb.ts'
11
+ import { connOf, downloadChunks, downloadStream, runScript, testConnection, uploadBuffer, uploadStream } from './engine/client.ts'
12
+ import { downloadSmb, smbReachable, smbUnavailable, uploadSmb, type TransferChannel } from './smb.ts'
13
13
  import { ConsoleSession } from './engine/console.ts'
14
14
  import { psKillProcess, psListDir, psListProcesses, psListServices, psServiceAction } from './powershell.ts'
15
15
 
@@ -244,18 +244,31 @@ export class WinRmEngine {
244
244
  return this.gate.run(alias, async () => {
245
245
  onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
246
246
  if (channel !== 'winrm') {
247
- try {
248
- const outcome = await uploadSmb(entry, localPath, remotePath)
249
- onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
250
- return { bytes: outcome.bytes, channel: outcome.channel }
251
- } catch (error) {
252
- if (channel === 'smb') throw error
253
- onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
247
+ // Probe 445 first: `net use` takes ~60 s to fail against a host whose
248
+ // SMB is down, and on `auto` that delay landed before every upload.
249
+ if (await smbReachable(entry.host)) {
250
+ try {
251
+ const outcome = await uploadSmb(entry, localPath, remotePath)
252
+ onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
253
+ return { bytes: outcome.bytes, channel: outcome.channel }
254
+ } catch (error) {
255
+ if (channel === 'smb') throw error
256
+ onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
257
+ }
258
+ } else if (channel === 'smb') {
259
+ throw smbUnavailable(entry.host)
254
260
  }
255
261
  }
256
- const bytes = await uploadBuffer(connOf(entry), remotePath, data, (transferred, total) => {
262
+ // WinRM transport: the streaming path moves the file over a single
263
+ // stdin stream (one shell, one Send per 256 KiB). It returns null when
264
+ // the transport it borrows is unavailable, in which case the chunked
265
+ // path still works — just far more slowly.
266
+ const conn = connOf(entry)
267
+ const report = (transferred: number, total: number): void => {
257
268
  onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
258
- })
269
+ }
270
+ let bytes = await uploadStream(conn, remotePath, data, report)
271
+ if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data, report)
259
272
  onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
260
273
  return { bytes, channel: 'winrm' }
261
274
  })
@@ -268,34 +281,85 @@ export class WinRmEngine {
268
281
  return this.gate.run(alias, async () => {
269
282
  onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
270
283
  if (channel !== 'winrm') {
271
- try {
272
- const outcome = await downloadSmb(entry, remotePath, localPath)
273
- onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
274
- return { bytes: outcome.bytes, channel: outcome.channel }
275
- } catch (error) {
276
- if (channel === 'smb') throw error
277
- onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
284
+ // Same 445 probe as upload: `net use` has no short timeout of its own.
285
+ if (await smbReachable(entry.host)) {
286
+ try {
287
+ const outcome = await downloadSmb(entry, remotePath, localPath)
288
+ onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
289
+ return { bytes: outcome.bytes, channel: outcome.channel }
290
+ } catch (error) {
291
+ if (channel === 'smb') throw error
292
+ onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
293
+ }
294
+ } else if (channel === 'smb') {
295
+ throw smbUnavailable(entry.host)
278
296
  }
279
297
  }
280
- const { createWriteStream } = await import('node:fs')
281
- const sink = createWriteStream(localPath, { mode: 0o600 })
298
+ const { open, rm } = await import('node:fs/promises')
299
+ // The destination is opened on the first write rather than up front, so a
300
+ // download that fails its size probe (a path that does not exist, a
301
+ // directory, an unreachable host) does not leave a truncated empty file
302
+ // behind as evidence that it succeeded.
303
+ let sink: Awaited<ReturnType<typeof open>> | null = null
304
+ let completed = false
282
305
  let bytes = 0
283
- await new Promise<void>((resolve, reject) => {
284
- sink.on('error', reject)
285
- void downloadChunks(
286
- connOf(entry),
287
- remotePath,
288
- (b64) => {
289
- const buffer = Buffer.from(b64, 'base64')
290
- bytes += buffer.length
291
- sink.write(buffer)
292
- },
293
- (transferred, total) => onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 }),
294
- ).then(
295
- () => sink.end(resolve),
296
- (error) => { sink.destroy(); reject(error) },
297
- )
298
- })
306
+ // Positional writes: the ranges arrive out of order and each piece knows
307
+ // its own offset, so a sequential stream would need full reassembly in
308
+ // memory first. pwrite keeps memory bounded by one Receive response.
309
+ // Writes are chained rather than fired loose: the handle must not close
310
+ // while a write is still in flight, and chaining bounds the pending work
311
+ // to a single write no matter how large the file is. A rejected write
312
+ // propagates down the chain and surfaces once at the final await.
313
+ let queue: Promise<void> = Promise.resolve()
314
+ const put = (offset: number, data: Buffer): void => {
315
+ bytes += data.length
316
+ queue = queue.then(async () => {
317
+ if (sink === null) sink = await open(localPath, 'w', 0o600)
318
+ const handle = sink
319
+ await handle.write(data, 0, data.length, offset)
320
+ })
321
+ // A failed write must not be reported as an unhandled rejection while
322
+ // the transfer is still running: the rejection is re-raised by the
323
+ // final await below, which is where the caller can act on it.
324
+ void queue.catch(() => undefined)
325
+ }
326
+ const report = (transferred: number, total: number): void => {
327
+ onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
328
+ }
329
+ try {
330
+ const conn = connOf(entry)
331
+ const streamed = await downloadStream(conn, remotePath, put, report)
332
+ if (streamed === null) {
333
+ let offset = 0
334
+ await downloadChunks(
335
+ conn,
336
+ remotePath,
337
+ (b64) => {
338
+ const buffer = Buffer.from(b64, 'base64')
339
+ put(offset, buffer)
340
+ offset += buffer.length
341
+ },
342
+ report,
343
+ )
344
+ }
345
+ await queue
346
+ // A zero-byte remote file produces no writes, so the sink is still
347
+ // unopened; create the file rather than finishing with nothing on disk.
348
+ if (sink === null) sink = await open(localPath, 'w', 0o600)
349
+ completed = true
350
+ } finally {
351
+ // `downloadStream` stops its workers from emitting before it settles,
352
+ // but a write already handed to the queue may still be in flight. Let
353
+ // it finish before the handle closes, or it lands on a closed file as
354
+ // an unhandled rejection that takes the whole process down.
355
+ await queue.catch(() => undefined)
356
+ if (sink !== null) await sink.close()
357
+ // A transfer that failed partway would otherwise leave a truncated file
358
+ // that looks exactly like a finished download. Removing it is safe even
359
+ // though it may have overwritten a previous file, because the 'w' open
360
+ // already truncated that file to nothing.
361
+ if (!completed && sink !== null) await rm(localPath, { force: true }).catch(() => undefined)
362
+ }
299
363
  onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
300
364
  return { bytes, channel: 'winrm' }
301
365
  })