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/README.md +14 -7
- package/README.zh.md +14 -7
- package/lib/client.js +73 -73
- package/lib/index.js +1184 -134
- package/lib/types/engine/client.d.ts +53 -6
- package/lib/types/engine/wsman.d.ts +153 -0
- package/lib/types/engine.d.ts +4 -4
- package/lib/types/powershell.d.ts +194 -27
- package/lib/types/smb.d.ts +24 -5
- package/package.json +17 -16
- package/scripts/verify-live.mts +116 -0
- package/src/engine/client.ts +460 -24
- package/src/engine/wsman.ts +357 -0
- package/src/engine.ts +104 -40
- package/src/powershell.ts +491 -60
- package/src/smb.ts +57 -5
- package/src/tools.ts +2 -2
package/src/powershell.ts
CHANGED
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PowerShell snippet builders
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
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 -
|
|
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
|
|
592
|
+
* Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
|
|
165
593
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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;
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
202
|
-
* envelope
|
|
203
|
-
*
|
|
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 {
|