dsh-winrm 0.1.1 → 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.
package/src/powershell.ts CHANGED
@@ -1,13 +1,52 @@
1
1
  /**
2
- * PowerShell snippet builders. Every payload rides the UTF-8 base64
3
- * envelope: the WinRM host prints only ASCII (marker + base64 of UTF-8 text)
4
- * so Chinese / any codepage output survives the transport losslessly. One-shot
5
- * commands execute directly in the PowerShell session created by WinRM.
2
+ * PowerShell snippet builders and the WinRM UTF-8 envelope.
3
+ *
4
+ * The user script is delivered on the WinRS **stdin** stream, not on the
5
+ * command line. WinRS hands the command line to cmd.exe, which caps it at
6
+ * ~8191 characters; `-EncodedCommand` inflates a script by 2.67x
7
+ * (UTF-16LE → base64), so the command line can carry only about 1.5 KB of
8
+ * script. That silently broke every large payload — most visibly the 48 KiB
9
+ * transfer chunk, whose embedded base64 alone is ~65K characters.
10
+ *
11
+ * So the command line is now a *constant* outer script and the user script
12
+ * rides stdin, which has no such limit (verified to 200 KB).
13
+ *
14
+ * The outer script reads a byte count, then that many bytes, starts a child
15
+ * `powershell.exe` and feeds it the script on its stdin. The process boundary
16
+ * is what makes `exit N` and an uncaught `throw` survivable: they terminate
17
+ * the *child*, and the outer script is still alive to print the envelope.
18
+ * `-EncodedCommand` for the child also sidesteps the execution policy
19
+ * entirely — the policy governs `.ps1` files, not encoded commands — so no
20
+ * temp file is written and no policy block can lose the envelope.
21
+ *
22
+ * The child body is `& (...) 2>&1 | Out-String -Stream`. Both halves matter:
23
+ * `2>&1` pulls error records into the success stream, `Out-String -Stream`
24
+ * stringifies them onto stdout *line by line*. Plain `Out-String` would also
25
+ * stringify them but buffers the whole pipeline, discarding everything
26
+ * produced before a mid-script `exit`; bare `2>&1` without `Out-String`
27
+ * leaves the records to be re-serialized as CLIXML on stderr, where the
28
+ * envelope's CLIXML stripping deletes the text outright.
29
+ *
30
+ * The envelope is three lines — a marker with the exit code, then one base64
31
+ * line per stream:
32
+ *
33
+ * __DSH_WINRM__<code>
34
+ * O:<base64 of UTF-8 stdout>
35
+ * E:<base64 of UTF-8 stderr>
36
+ *
37
+ * Every payload is ASCII, so the transport cannot corrupt it and Chinese
38
+ * output survives losslessly. The `O:` / `E:` labels are load-bearing: the
39
+ * WinRM client trims each response chunk, which can eat the newline between
40
+ * two lines, and base64 never contains `:`, so the labels stay parseable
41
+ * even when the separators vanish.
6
42
  */
7
43
 
8
44
  /** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
9
45
  export const ENVELOPE_MARKER = '__DSH_WINRM__'
10
46
 
47
+ /** Max characters per stdin Send; split on code points, never mid-surrogate-pair. */
48
+ export const STDIN_CHUNK_CHARS = 16_384
49
+
11
50
  /** PS single-quoted literal (doubles embedded quotes). */
12
51
  export function sq(value: string): string {
13
52
  return "'" + value.replace(/'/g, "''") + "'"
@@ -19,48 +58,187 @@ export function encodeCommand(script: string): string {
19
58
  }
20
59
 
21
60
  /**
22
- * The UTF-8 WinRM session script: runs `userScript` inside a script block,
23
- * merges stderr, prints the exit-code marker and base64 of UTF-8 output.
61
+ * Child script: reads the user script from its own stdin, runs it, and lets
62
+ * `exit` end *this* process with the right code.
63
+ *
64
+ * The output shape is fixed by how PowerShell surfaces errors. A terminating
65
+ * `throw` is reported by the `catch`; a non-terminating error only reaches the
66
+ * success stream via `2>&1`, and `Out-String -Stream` turns each such record
67
+ * into text as it arrives. `$LASTEXITCODE` is preferred over the accumulated
68
+ * code so `cmd /c exit 7` reports 7 rather than 0.
24
69
  */
25
- export function wrapEnvelope(userScript: string): string {
26
- return [
27
- "$ErrorActionPreference = 'Continue'",
28
- "$__out = & { " + userScript + " } 2>&1 | Out-String -Width 4096",
29
- "$__code = 0",
30
- "if ($null -ne $LASTEXITCODE) { $__code = $LASTEXITCODE }",
31
- "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
32
- "'__DSH_WINRM__' + [string]$__code",
33
- "[System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__out))",
34
- '',
35
- ].join('\r\n')
36
- }
70
+ const CHILD_SCRIPT = [
71
+ "$ErrorActionPreference='Continue'",
72
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8',
73
+ '$i=[Console]::OpenStandardInput()',
74
+ '$m=New-Object System.IO.MemoryStream',
75
+ '$i.CopyTo($m)',
76
+ '$s=[Text.Encoding]::UTF8.GetString($m.ToArray())',
77
+ 'if($s.Length -gt 0 -and [int]$s[0] -eq 0xFEFF){$s=$s.Substring(1)}',
78
+ '$c=0',
79
+ 'try{ &([scriptblock]::Create($s)) 2>&1 | Out-String -Stream -Width 4096',
80
+ 'if($null -ne $LASTEXITCODE){$c=$LASTEXITCODE}',
81
+ '}catch{ $_|Out-String; $c=1 }',
82
+ 'exit $c',
83
+ '',
84
+ ].join('\r\n')
85
+
86
+ /**
87
+ * Outer script (constant, so the command line never grows with the payload):
88
+ * read `<byteCount>\n<utf8 script>` from stdin, run it in a child
89
+ * powershell.exe, then print the three-line envelope.
90
+ *
91
+ * The child is started with redirected streams *before* anything is written to
92
+ * its stdin, and both readers are started before the write, so a child that
93
+ * fills a pipe buffer cannot deadlock the outer script. Closing the writer is
94
+ * what gives the child a real EOF — without it the child would block forever
95
+ * on `CopyTo`.
96
+ */
97
+ const OUTER_SCRIPT = [
98
+ "$ErrorActionPreference='Continue'",
99
+ "$z=@'",
100
+ CHILD_SCRIPT,
101
+ "'@",
102
+ "$a=''",
103
+ "$e=''",
104
+ '$c=0',
105
+ 'try{',
106
+ ' $i=[Console]::OpenStandardInput()',
107
+ " $d=''",
108
+ ' while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}',
109
+ ' $n=[int]$d',
110
+ ' $u=New-Object byte[] $n',
111
+ ' $r=0',
112
+ ' while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}',
113
+ ' $s=[Text.Encoding]::UTF8.GetString($u,0,$r)',
114
+ ' $p=New-Object System.Diagnostics.ProcessStartInfo',
115
+ " $p.FileName='powershell.exe'",
116
+ ' $p.UseShellExecute=$false',
117
+ ' $p.RedirectStandardOutput=$true',
118
+ ' $p.RedirectStandardError=$true',
119
+ ' $p.RedirectStandardInput=$true',
120
+ ' $p.StandardOutputEncoding=[Text.Encoding]::UTF8',
121
+ ' $p.StandardErrorEncoding=[Text.Encoding]::UTF8',
122
+ ' $p.CreateNoWindow=$true',
123
+ " $p.Arguments='-NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '+[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($z))",
124
+ ' $q=[System.Diagnostics.Process]::Start($p)',
125
+ ' $x=$q.StandardOutput.ReadToEndAsync()',
126
+ ' $y=$q.StandardError.ReadToEndAsync()',
127
+ ' $w=New-Object System.IO.StreamWriter($q.StandardInput.BaseStream,(New-Object System.Text.UTF8Encoding($false)))',
128
+ ' $w.Write($s)',
129
+ ' $w.Flush()',
130
+ ' $w.Close()',
131
+ ' $q.WaitForExit()',
132
+ ' $a=[string]$x.Result',
133
+ ' $e=[string]$y.Result',
134
+ ' $c=$q.ExitCode',
135
+ '}catch{',
136
+ ' $a=[string]$a+[string]($_|Out-String)',
137
+ ' $c=1',
138
+ '}',
139
+ sq(ENVELOPE_MARKER) + '+[string]$c',
140
+ "'O:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$a))",
141
+ "'E:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$e))",
142
+ '',
143
+ ].join('\r\n')
37
144
 
38
- /** One-shot script body for the PowerShell session already created by WinRM. */
39
- export function winrmPowerShellScript(userScript: string): string {
40
- return wrapEnvelope(userScript)
145
+ /**
146
+ * The constant powershell.exe command line that runs the envelope.
147
+ *
148
+ * Deliberately takes no payload: keeping the command line under cmd.exe's
149
+ * ~8191-character cap is the whole point of the stdin channel.
150
+ */
151
+ export function powershellCommandLine(): string {
152
+ return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(OUTER_SCRIPT)
41
153
  }
42
154
 
43
- /** The full powershell.exe command line (ASCII-safe) for a script body. */
44
- export function powershellCommandLine(script: string): string {
45
- return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(script)
155
+ /**
156
+ * Bootstrap for the streaming upload path: read `<byteCount>\n<script>` from
157
+ * stdin, then run that script **in this process** so it can keep draining the
158
+ * same stdin stream.
159
+ *
160
+ * This exists because {@link psReceiveStream} outgrew the command line. WinRS
161
+ * hands the command line to cmd.exe, whose limit is about 8191 characters, and
162
+ * the receiver is 3424 characters of PowerShell — 9206 once base64-of-UTF-16
163
+ * expands it, over the cap. Shipping the receiver on stdin instead costs one
164
+ * extra `Send` and keeps the command line a constant 1154 characters no matter
165
+ * how the receiver grows.
166
+ *
167
+ * Running the receiver in-process (rather than in a child, as
168
+ * {@link OUTER_SCRIPT} does) is what makes the handoff work: the child would
169
+ * need its own stdin, and the payload has to keep flowing into whatever reads
170
+ * the file. A fresh `[Console]::OpenStandardInput()` inside the receiver picks
171
+ * up exactly where this bootstrap stopped reading, because `Read` on the raw
172
+ * stream never buffers ahead of the count it was asked for.
173
+ */
174
+ const STREAM_BOOTSTRAP = [
175
+ "$ErrorActionPreference='Continue'",
176
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8',
177
+ '$i=[Console]::OpenStandardInput()',
178
+ "$d=''",
179
+ 'while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}',
180
+ '$n=[int]$d',
181
+ '$u=New-Object byte[] $n',
182
+ '$r=0',
183
+ 'while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}',
184
+ '$s=[Text.Encoding]::UTF8.GetString($u,0,$r)',
185
+ '$c=0',
186
+ 'try{',
187
+ ' &([scriptblock]::Create($s))',
188
+ '}catch{',
189
+ ' [Console]::Error.WriteLine([string]($_|Out-String))',
190
+ ' $c=1',
191
+ '}',
192
+ 'exit $c',
193
+ '',
194
+ ].join('\r\n')
195
+
196
+ /**
197
+ * The constant powershell.exe command line for a streaming upload.
198
+ *
199
+ * The receiver travels on stdin right behind it, so this stays well clear of
200
+ * cmd.exe's cap and does not change when the receiver does.
201
+ */
202
+ export function streamCommandLine(): string {
203
+ return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(STREAM_BOOTSTRAP)
46
204
  }
47
205
 
48
- /** One-shot non-interactive PowerShell command line (envelope-wrapped). */
49
- export function execCommandLine(userScript: string): string {
50
- return powershellCommandLine(wrapEnvelope(userScript))
206
+ /**
207
+ * The stdin payload for one command: the UTF-8 **byte** count, a newline, then
208
+ * the raw script.
209
+ *
210
+ * The count is in bytes, not characters, because the outer script sizes its
211
+ * receive buffer from it — a script with multi-byte characters would otherwise
212
+ * be truncated mid-character.
213
+ * @param userScript - the PowerShell script to run.
214
+ * @returns the payload to send on WinRS stdin.
215
+ */
216
+ export function scriptPayload(userScript: string): string {
217
+ return String(Buffer.byteLength(userScript, 'utf8')) + '\n' + userScript
51
218
  }
52
219
 
53
- /** Interactive bootstrap for the streaming console (keeps the shell alive). */
54
- export function consoleCommandLine(): string {
55
- const bootstrap = [
56
- "$ErrorActionPreference = 'Continue'",
57
- "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
58
- "'__DSH_WINRM_CONSOLE_READY__'",
59
- '',
60
- ].join('\r\n')
61
- // -NoExit keeps powershell.exe alive reading stdin (interactive mode).
62
- return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '
63
- + encodeCommand(bootstrap) + ' -NoExit'
220
+ /**
221
+ * Split a payload into Send-sized pieces without cutting a surrogate pair.
222
+ *
223
+ * `Command.doSendInput` encodes each piece to UTF-8 independently, so a lone
224
+ * surrogate at a chunk boundary would be replaced with U+FFFD and corrupt the
225
+ * script. Iterating with `for…of` yields whole code points, so a pair always
226
+ * stays in one piece.
227
+ * @param payload - the full stdin payload.
228
+ * @returns the pieces to send in order; never empty.
229
+ */
230
+ export function splitScriptPayload(payload: string): string[] {
231
+ const pieces: string[] = []
232
+ let piece = ''
233
+ for (const ch of payload) {
234
+ if (piece.length + ch.length > STDIN_CHUNK_CHARS) {
235
+ pieces.push(piece)
236
+ piece = ''
237
+ }
238
+ piece += ch
239
+ }
240
+ if (piece !== '' || pieces.length === 0) pieces.push(piece)
241
+ return pieces
64
242
  }
65
243
 
66
244
  // ------------------------------------------------------------ snippets
@@ -118,11 +296,16 @@ export function psListDir(dir: string): string {
118
296
  ].join('\r\n')
119
297
  }
120
298
 
121
- /** Plain: remote file size in bytes (0 when missing). */
299
+ /**
300
+ * Plain: remote file size in bytes. Emits nothing at all when the path is
301
+ * missing or is a directory, so the caller can tell "absent" from "empty" —
302
+ * both used to collapse to the string "0", which made a download of a
303
+ * nonexistent file look like a successful transfer of a zero-byte one.
304
+ */
122
305
  export function psFileSize(p: string): string {
123
306
  return [
124
307
  '$__f = Get-Item -LiteralPath ' + sq(p) + ' -ErrorAction SilentlyContinue',
125
- 'if ($null -eq $__f) { "0" } elseif ($__f.PSIsContainer) { "0" } else { [string]$__f.Length }',
308
+ 'if ($null -ne $__f -and -not $__f.PSIsContainer) { [string]$__f.Length }',
126
309
  '',
127
310
  ].join('\r\n')
128
311
  }
@@ -160,25 +343,272 @@ export function psWriteChunk(p: string, b64: string, append: boolean): string {
160
343
  ].join('\r\n')
161
344
  }
162
345
 
346
+ /** Magic stamped into the frame header that precedes every stream chunk. */
347
+ export const STREAM_FRAME_MAGIC = 'DSHZ'
348
+
349
+ /** Frame header size: 4 magic bytes + 4 little-endian length bytes + 1 mode byte + 1 reserved. */
350
+ export const STREAM_FRAME_HEADER_BYTES = 10
351
+
352
+ /** Frame mode: the payload after the header is a gzip member. */
353
+ export const STREAM_FRAME_GZIP = 1
354
+
355
+ /** Frame mode: the payload after the header is raw file bytes. */
356
+ export const STREAM_FRAME_RAW = 0
357
+
358
+ /** Frame mode: the payload after the header is the transfer's metadata. */
359
+ export const STREAM_FRAME_HEADER = 2
360
+
361
+ /** Remote-side buffer used while draining stdin (or inflating a frame) into the file. */
362
+ const STREAM_BUFFER_BYTES = 262_144
363
+
364
+ /**
365
+ * Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
366
+ *
367
+ * The frame is what makes a compressed stream legal on a pipe. stdin cannot
368
+ * seek, so the receiver has to be handed the exact byte count of the next gzip
369
+ * member before it lets `GZipStream` touch it; otherwise the inflater would
370
+ * read into the member that follows and the stream would desynchronize. The
371
+ * mode byte travels with the payload so the client can switch between gzip and
372
+ * raw per chunk.
373
+ * @param payload - gzip member bytes or raw file bytes.
374
+ * @param mode - `STREAM_FRAME_GZIP` or `STREAM_FRAME_RAW`.
375
+ * @returns the framed chunk to place on the stdin stream.
376
+ */
377
+ export function buildStreamFrame(payload: Buffer, mode: number): Buffer {
378
+ const frame = Buffer.alloc(STREAM_FRAME_HEADER_BYTES + payload.length)
379
+ frame.write(STREAM_FRAME_MAGIC, 0, 'ascii')
380
+ frame.writeUInt32LE(payload.length, 4)
381
+ frame[8] = mode
382
+ frame[9] = 0
383
+ payload.copy(frame, STREAM_FRAME_HEADER_BYTES)
384
+ return frame
385
+ }
386
+
387
+ /**
388
+ * Streaming receiver: drain framed stdin chunks into the file named by the
389
+ * header frame, until the declared byte count lands.
390
+ *
391
+ * This is the fast upload path. Instead of shipping base64 text inside a
392
+ * PowerShell script — which costs one full shell create/command/delete cycle
393
+ * per 48 KiB — the remote shell starts this script once and the client then
394
+ * pushes bytes straight down the WinRS stdin stream. The only per-chunk cost
395
+ * left is a single `Send`, so a whole file costs one shell and one command no
396
+ * matter how large it is.
397
+ *
398
+ * The script deliberately takes **no arguments**. The destination path and
399
+ * total byte count arrive as the first frame on stdin, which keeps the command
400
+ * line a constant size: WinRS hands the command line to cmd.exe, whose limit
401
+ * is about 8191 characters, and a long remote path interpolated into the
402
+ * script would eat into that budget (the script is base64-of-UTF-16 on the
403
+ * wire, so every character costs 2.67). Reading them from the stream removes
404
+ * the limit entirely and mirrors how `powershellCommandLine` stays constant.
405
+ *
406
+ * Every wire chunk is a frame: `DSHZ` + 4-byte little-endian payload length +
407
+ * 1 mode byte + 1 reserved, followed by exactly that many payload bytes. The
408
+ * explicit frame is what makes a *pipe* usable for a compressed stream: stdin
409
+ * cannot seek, so the receiver must be told how long the next gzip member is
410
+ * before it hands the bytes to `GZipStream`, or the inflater would read into
411
+ * the member that follows. The mode byte lets the client pick per chunk — gzip
412
+ * when it actually shrinks the data, raw bytes when it would not — so
413
+ * incompressible files never pay compression overhead.
414
+ *
415
+ * The script prints the three-line envelope on stdout with the byte count as
416
+ * its payload, so the caller can prove the transfer landed complete.
417
+ * @returns a PowerShell script suitable for `-EncodedCommand`.
418
+ */
419
+ export function psReceiveStream(): string {
420
+ return [
421
+ "$ErrorActionPreference = 'Stop'",
422
+ "$__a = ''",
423
+ "$__e = ''",
424
+ '$__c = 0',
425
+ 'try {',
426
+ ' $__i = [Console]::OpenStandardInput()',
427
+ ' $__hdr = New-Object byte[] ' + String(STREAM_FRAME_HEADER_BYTES),
428
+ ' $__got = 0',
429
+ ' while ($__got -lt $__hdr.Length) {',
430
+ ' $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)',
431
+ ' if ($__k -le 0) { break }',
432
+ ' $__got += $__k',
433
+ ' }',
434
+ ' if ($__got -lt $__hdr.Length) { throw ' + sq('upload stream ended before its header') + ' }',
435
+ ' if (-not ($__hdr[0] -eq 68 -and $__hdr[1] -eq 83 -and $__hdr[2] -eq 72 -and $__hdr[3] -eq 90)) { throw ' + sq('upload stream lost frame alignment') + ' }',
436
+ ' if ($__hdr[8] -ne ' + String(STREAM_FRAME_HEADER) + ') { throw ' + sq('upload stream started without a header frame') + ' }',
437
+ ' $__size = 0',
438
+ ' for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }',
439
+ ' $__meta = New-Object byte[] $__size',
440
+ ' $__got = 0',
441
+ ' while ($__got -lt $__size) {',
442
+ ' $__k = $__i.Read($__meta, $__got, $__size - $__got)',
443
+ ' if ($__k -le 0) { break }',
444
+ ' $__got += $__k',
445
+ ' }',
446
+ ' if ($__got -lt $__size) { throw ' + sq('upload stream truncated its header frame') + ' }',
447
+ ' $__text = [System.Text.Encoding]::UTF8.GetString($__meta)',
448
+ ' $__cut = $__text.IndexOf([char]10)',
449
+ ' if ($__cut -lt 0) { throw ' + sq('upload header frame is malformed') + ' }',
450
+ ' $__n = [long]$__text.Substring(0, $__cut)',
451
+ ' $__p = $__text.Substring($__cut + 1)',
452
+ ' $__d = [System.IO.Path]::GetDirectoryName($__p)',
453
+ ' if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }',
454
+ ' $__fs = New-Object System.IO.FileStream($__p, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)',
455
+ ' $__w = 0',
456
+ ' try {',
457
+ ' $__buf = New-Object byte[] ' + String(STREAM_BUFFER_BYTES),
458
+ ' while ($__w -lt $__n) {',
459
+ ' $__got = 0',
460
+ ' while ($__got -lt $__hdr.Length) {',
461
+ ' $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)',
462
+ ' if ($__k -le 0) { break }',
463
+ ' $__got += $__k',
464
+ ' }',
465
+ ' if ($__got -lt $__hdr.Length) { break }',
466
+ ' if (-not ($__hdr[0] -eq 68 -and $__hdr[1] -eq 83 -and $__hdr[2] -eq 72 -and $__hdr[3] -eq 90)) { throw ' + sq('upload stream lost frame alignment') + ' }',
467
+ ' $__size = 0',
468
+ ' for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }',
469
+ ' $__mode = $__hdr[8]',
470
+ ' $__blob = New-Object byte[] $__size',
471
+ ' $__got = 0',
472
+ ' while ($__got -lt $__size) {',
473
+ ' $__k = $__i.Read($__blob, $__got, $__size - $__got)',
474
+ ' if ($__k -le 0) { break }',
475
+ ' $__got += $__k',
476
+ ' }',
477
+ ' if ($__got -lt $__size) { break }',
478
+ ' if ($__mode -eq ' + String(STREAM_FRAME_GZIP) + ') {',
479
+ ' $__ms = New-Object System.IO.MemoryStream(, $__blob)',
480
+ ' $__gz = New-Object System.IO.Compression.GZipStream($__ms, [System.IO.Compression.CompressionMode]::Decompress)',
481
+ ' try {',
482
+ ' while ($true) {',
483
+ ' $__k = $__gz.Read($__buf, 0, $__buf.Length)',
484
+ ' if ($__k -le 0) { break }',
485
+ ' $__fs.Write($__buf, 0, $__k)',
486
+ ' $__w += $__k',
487
+ ' }',
488
+ ' } finally { $__gz.Dispose(); $__ms.Dispose() }',
489
+ ' } else {',
490
+ ' $__fs.Write($__blob, 0, $__blob.Length)',
491
+ ' $__w += $__blob.Length',
492
+ ' }',
493
+ ' }',
494
+ ' } finally { $__fs.Dispose() }',
495
+ ' $__a = [string]$__w',
496
+ '} catch {',
497
+ ' $__a = [string]$__a + [string]($_.Exception.Message)',
498
+ ' $__c = 1',
499
+ '}',
500
+ sq(ENVELOPE_MARKER) + ' + [string]$__c',
501
+ "'O:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__a))",
502
+ "'E:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__e))",
503
+ '',
504
+ ].join('\r\n')
505
+ }
506
+
507
+ /** Remote-side read buffer used by {@link psSendStream} while draining the file to stdout. */
508
+ const SEND_BUFFER_BYTES = 262_144
509
+
510
+ /**
511
+ * Streaming sender: write one byte range of the file to standard output,
512
+ * gzip-compressed, for the download path.
513
+ *
514
+ * This is the mirror of {@link psReceiveStream}. The old download path asked
515
+ * for 48 KiB at a time, and every chunk cost a full shell create / command /
516
+ * delete cycle — five HTTP round trips each — so a 2 MiB file took 129 s. Here
517
+ * the shell is created once and the client then simply receives; the only
518
+ * per-chunk cost left is one `Receive`, and the target streams the file as fast
519
+ * as the transport will drain it.
520
+ *
521
+ * Two details make this work:
522
+ *
523
+ * - **Only stderr may carry text.** Anything written to the PowerShell success
524
+ * stream would be merged into the payload and corrupt the file, so the byte
525
+ * count and any error go to `[Console]::Error` and the exit code, not stdout.
526
+ * - **The range is positional.** `Position` plus a byte count lets several
527
+ * shells serve different parts of one file concurrently; each writes to its
528
+ * own offset on the client, so completion order does not matter.
529
+ *
530
+ * Output is gzip-compressed unconditionally. Measured against this target, a
531
+ * compressible 16 MiB file collapsed from 121 round trips to 1 (2.0 s), and an
532
+ * incompressible 2 MiB file was no slower than raw (9457 ms vs 9582 ms), so the
533
+ * compression pays for itself or costs nothing.
534
+ * @param remotePath - file to read on the target.
535
+ * @param start - first byte offset of this worker's range.
536
+ * @param length - number of bytes this worker must send.
537
+ * @returns a PowerShell script suitable for `-EncodedCommand`.
538
+ */
539
+ export function psSendStream(remotePath: string, start: number, length: number): string {
540
+ return [
541
+ "$ErrorActionPreference = 'Stop'",
542
+ '$__o = [Console]::OpenStandardOutput()',
543
+ '$__n = 0',
544
+ 'try {',
545
+ ' $__fs = [System.IO.File]::OpenRead(' + sq(remotePath) + ')',
546
+ ' try {',
547
+ ' $__fs.Position = [long]' + String(start),
548
+ ' $__left = [long]' + String(length),
549
+ ' $__gz = New-Object System.IO.Compression.GZipStream($__o, [System.IO.Compression.CompressionMode]::Compress, $true)',
550
+ ' try {',
551
+ ' $__buf = New-Object byte[] ' + String(SEND_BUFFER_BYTES),
552
+ ' while ($__left -gt 0) {',
553
+ ' $__want = [int][Math]::Min([long]$__buf.Length, $__left)',
554
+ ' $__k = $__fs.Read($__buf, 0, $__want)',
555
+ ' if ($__k -le 0) { break }',
556
+ ' $__gz.Write($__buf, 0, $__k)',
557
+ ' $__n += $__k',
558
+ ' $__left -= $__k',
559
+ ' }',
560
+ ' $__gz.Flush()',
561
+ ' } finally { $__gz.Dispose() }',
562
+ ' $__o.Flush()',
563
+ ' } finally { $__fs.Dispose() }',
564
+ " [Console]::Error.WriteLine('__DSH_DL__ OK ' + [string]$__n)",
565
+ '} catch {',
566
+ " [Console]::Error.WriteLine('__DSH_DL__ ERR ' + [string]($_.Exception.Message))",
567
+ ' throw',
568
+ '}',
569
+ '',
570
+ ].join('\r\n')
571
+ }
572
+
573
+ /**
574
+ * Wrap an arbitrary script in the constant powershell.exe invocation.
575
+ *
576
+ * Only safe for scripts that fit cmd.exe's ~8191 character command line (the
577
+ * script is base64-of-UTF-16 on the wire, so it costs 2.67 characters each);
578
+ * {@link psReceiveStream} outgrew that and travels on stdin instead.
579
+ * @param script - the PowerShell script to run.
580
+ * @returns the command line to hand to WinRS.
581
+ */
582
+ export function scriptCommandLine(script: string): string {
583
+ return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(script)
584
+ }
585
+
586
+ /** Decode one base64 envelope field, tolerating whitespace the transport added. */
587
+ function decodeField(base64: string): string {
588
+ return Buffer.from(base64.replace(/\s+/g, ''), 'base64').toString('utf8')
589
+ }
590
+
163
591
  /**
164
- * Parse the envelope output: { exitCode, text } or null when absent.
592
+ * Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
165
593
  *
166
- * The base64 body is optional and the separating newline may be missing: a
167
- * command that printed nothing emits the marker alone (an empty string has
168
- * no base64 body), and a transport that trims each response drops the
169
- * trailing newline. The exit code is signed because `$LASTEXITCODE` can be
170
- * negative.
594
+ * Fields are located by their `O:` / `E:` labels rather than by splitting on
595
+ * newlines, because the WinRM client trims every response chunk and can drop
596
+ * the newline that separated two lines. base64's alphabet has no colon, so a
597
+ * label can only ever be a delimiter and the search cannot land on payload
598
+ * data. The exit code is signed because `$LASTEXITCODE` can be negative.
599
+ * @param output - the raw stdout accumulated from the WinRS stdout stream.
600
+ * @returns the decoded envelope, or null when the marker is missing.
171
601
  */
172
- export function parseEnvelope(output: string): { exitCode: number; text: string } | null {
173
- const match = /^__DSH_WINRM__(-?\d+)(?:\r?\n([\s\S]*))?$/.exec(output)
174
- if (match === null) return null
175
- const exitCode = Number.parseInt(match[1], 10)
176
- const payload = (match[2] ?? '').trim()
177
- try {
178
- const text = Buffer.from(payload, 'base64').toString('utf8')
179
- return { exitCode, text }
180
- } catch {
181
- return { exitCode, text: payload }
602
+ export function parseEnvelope(output: string): { exitCode: number; stdout: string; stderr: string } | null {
603
+ const text = output.trim()
604
+ const head = new RegExp('^' + ENVELOPE_MARKER + '(-?\\d+)').exec(text)
605
+ if (head === null) return null
606
+ const stdoutAt = text.indexOf('O:')
607
+ const stderrAt = stdoutAt === -1 ? -1 : text.indexOf('E:', stdoutAt + 2)
608
+ return {
609
+ exitCode: Number.parseInt(head[1], 10),
610
+ stdout: stdoutAt === -1 ? '' : decodeField(text.slice(stdoutAt + 2, stderrAt === -1 ? undefined : stderrAt)),
611
+ stderr: stderrAt === -1 ? '' : decodeField(text.slice(stderrAt + 2)),
182
612
  }
183
613
  }
184
614
 
@@ -189,7 +619,7 @@ const CLIXML_HEADER = /^#< CLIXML\r?$/gm
189
619
  const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g
190
620
 
191
621
  /**
192
- * Remove PowerShell's CLIXML host records from the WinRS stderr stream.
622
+ * Remove PowerShell's CLIXML host records from a stderr stream.
193
623
  *
194
624
  * PowerShell marks its stderr stream with a `#< CLIXML` header line and
195
625
  * serializes the remote host's own progress/verbose records into `<Objs>`
@@ -198,9 +628,10 @@ const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g
198
628
  * header and a record block (for example `[Console]::Error`) is real output,
199
629
  * not part of the CLIXML payload.
200
630
  *
201
- * The script's own error output is already merged into stdout by the
202
- * envelope, so this only removes transport-level noise.
203
- * @param stderr - raw stderr text captured from the WinRS stream.
631
+ * The script's own PowerShell errors are already stringified into the
632
+ * envelope's stdout by `Out-String -Stream`, so this only removes
633
+ * transport-level noise such as module-load progress.
634
+ * @param stderr - raw stderr text captured from the WinRS stream or the envelope.
204
635
  * @returns the stderr text without CLIXML host records.
205
636
  */
206
637
  export function stripClixml(stderr: string): string {