ruby-pwsh 0.10.2 → 0.11.0.rc.1
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +16 -38
- data/README.md +29 -25
- data/lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb +23 -20
- data/lib/pwsh/version.rb +1 -1
- data/lib/pwsh/windows_powershell.rb +2 -2
- data/lib/pwsh.rb +31 -34
- data/spec/acceptance/dsc/basic.rb +209 -0
- data/spec/acceptance/dsc/cim_instances.rb +81 -0
- data/spec/acceptance/dsc/class.rb +129 -0
- data/spec/acceptance/dsc/complex.rb +139 -0
- data/spec/acceptance/support/setup_winrm.ps1 +6 -0
- data/spec/exit-27.ps1 +1 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb +2084 -0
- data/spec/unit/pwsh/util_spec.rb +293 -0
- data/spec/unit/pwsh/version_spec.rb +10 -0
- data/spec/unit/pwsh/windows_powershell_spec.rb +121 -0
- data/spec/unit/pwsh_spec.rb +821 -0
- metadata +18 -22
- data/.gitattributes +0 -2
- data/.github/workflows/ci.yml +0 -109
- data/.gitignore +0 -23
- data/.pmtignore +0 -21
- data/.rspec +0 -3
- data/CHANGELOG.md +0 -204
- data/CODEOWNERS +0 -2
- data/CONTRIBUTING.md +0 -155
- data/DESIGN.md +0 -70
- data/Gemfile +0 -54
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -188
- data/design-comms.png +0 -0
- data/metadata.json +0 -82
- data/pwshlib.md +0 -92
- data/ruby-pwsh.gemspec +0 -39
@@ -0,0 +1,821 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'ruby-pwsh'
|
5
|
+
|
6
|
+
module Pwsh
|
7
|
+
class Manager; end
|
8
|
+
if Pwsh::Util.on_windows?
|
9
|
+
module WindowsAPI
|
10
|
+
require 'ffi'
|
11
|
+
extend FFI::Library
|
12
|
+
|
13
|
+
ffi_convention :stdcall
|
14
|
+
|
15
|
+
# https://msdn.microsoft.com/en-us/library/ks2530z6%28v=VS.100%29.aspx
|
16
|
+
# intptr_t _get_osfhandle(
|
17
|
+
# int fd
|
18
|
+
# );
|
19
|
+
ffi_lib [FFI::CURRENT_PROCESS, 'msvcrt']
|
20
|
+
attach_function :get_osfhandle, :_get_osfhandle, [:int], :uintptr_t
|
21
|
+
|
22
|
+
# http://msdn.microsoft.com/en-us/library/windows/desktop/ms724211(v=vs.85).aspx
|
23
|
+
# BOOL WINAPI CloseHandle(
|
24
|
+
# _In_ HANDLE hObject
|
25
|
+
# );
|
26
|
+
ffi_lib :kernel32
|
27
|
+
attach_function :CloseHandle, [:uintptr_t], :int32
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
RSpec.shared_examples 'a PowerShellCodeManager' do |ps_command, ps_args|
|
33
|
+
describe Pwsh::Manager do
|
34
|
+
def line_end
|
35
|
+
Pwsh::Util.on_windows? ? "\r\n" : "\n"
|
36
|
+
end
|
37
|
+
|
38
|
+
def is_osx?
|
39
|
+
# Note this test fails if running in JRuby, but because the unit tests are MRI only, this is ok
|
40
|
+
!RUBY_PLATFORM.include?('darwin').nil?
|
41
|
+
end
|
42
|
+
|
43
|
+
let(:manager) { described_class.instance(ps_command, ps_args) }
|
44
|
+
|
45
|
+
let(:powershell_incompleteparseexception_error) { '$ErrorActionPreference = "Stop";if (1 -eq 2) { ' }
|
46
|
+
let(:powershell_parseexception_error) { '$ErrorActionPreference = "Stop";if (1 -badoperator 2) { Exit 1 }' }
|
47
|
+
let(:powershell_runtime_error) { '$ErrorActionPreference = "Stop";$test = 1/0' }
|
48
|
+
|
49
|
+
describe 'when managing the powershell process' do
|
50
|
+
describe 'the Manager::instance method' do
|
51
|
+
it 'returns the same manager instance / process given the same cmd line and options' do
|
52
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
53
|
+
|
54
|
+
manager_two = described_class.instance(ps_command, ps_args)
|
55
|
+
second_pid = manager_two.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
56
|
+
|
57
|
+
expect(manager_two).to eq(manager)
|
58
|
+
expect(first_pid).to eq(second_pid)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'returns different manager instances / processes given the same cmd line and different options' do
|
62
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
63
|
+
|
64
|
+
manager_two = described_class.instance(ps_command, ps_args, { some_option: 'foo' })
|
65
|
+
second_pid = manager_two.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
66
|
+
|
67
|
+
expect(manager_two).not_to eq(manager)
|
68
|
+
expect(first_pid).not_to eq(second_pid)
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'fails if the manger is created with a short timeout' do
|
72
|
+
expect { described_class.new(ps_command, ps_args, debug: false, pipe_timeout: 0.01) }.to raise_error do |e|
|
73
|
+
expect(e).to be_a(RuntimeError)
|
74
|
+
expected_error = /Failure waiting for PowerShell process (\d+) to start pipe server/
|
75
|
+
expect(e.message).to match expected_error
|
76
|
+
pid = expected_error.match(e.message)[1].to_i
|
77
|
+
|
78
|
+
# We want to make sure that enough time has elapsed since the manager called kill
|
79
|
+
# for the OS to finish killing the process and doing all of it's cleanup.
|
80
|
+
# We have found that without an appropriate wait period, the kill call below
|
81
|
+
# can return unexpected results and fail the test.
|
82
|
+
sleep(1)
|
83
|
+
expect { Process.kill(0, pid) }.to raise_error(Errno::ESRCH)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def bad_file_descriptor_regex
|
88
|
+
# Ruby can do something like:
|
89
|
+
# <Errno::EBADF: Bad file descriptor>
|
90
|
+
# <Errno::EBADF: Bad file descriptor @ io_fillbuf - fd:10 >
|
91
|
+
@bad_file_descriptor_regex ||= begin
|
92
|
+
ebadf = Errno::EBADF.new
|
93
|
+
"^#{Regexp.escape("#<#{ebadf.class}: #{ebadf.message}")}"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def pipe_error_regex
|
98
|
+
@pipe_error_regex ||= begin
|
99
|
+
epipe = Errno::EPIPE.new
|
100
|
+
"^#{Regexp.escape("#<#{epipe.class}: #{epipe.message}")}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# reason should be a string for an exact match
|
105
|
+
# else an array of regex matches
|
106
|
+
def expect_dead_manager(manager, reason, style = :exact)
|
107
|
+
# additional attempts to use the manager will fail for the given reason
|
108
|
+
result = manager.execute('Write-Host "hi"')
|
109
|
+
expect(result[:exitcode]).to eq(-1)
|
110
|
+
|
111
|
+
case reason
|
112
|
+
when String
|
113
|
+
expect(result[:stderr][0]).to eq(reason) if style == :exact
|
114
|
+
expect(result[:stderr][0]).to match(reason) if style == :regex
|
115
|
+
when Array
|
116
|
+
expect(reason).to include(result[:stderr][0]) if style == :exact
|
117
|
+
if style == :regex
|
118
|
+
expect(result[:stderr][0]).to satisfy("should match expected error(s): #{reason}") do |msg|
|
119
|
+
reason.any? { |m| msg.match m }
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# and the manager no longer considers itself alive
|
125
|
+
expect(manager.alive?).to be(false)
|
126
|
+
end
|
127
|
+
|
128
|
+
def expect_different_manager_returned_than(manager, pid)
|
129
|
+
# acquire another manager instance using the same command and arguments
|
130
|
+
new_manager = Pwsh::Manager.instance(manager.powershell_command, manager.powershell_arguments, debug: true)
|
131
|
+
|
132
|
+
# which should be different than the one passed in
|
133
|
+
expect(new_manager).not_to eq(manager)
|
134
|
+
|
135
|
+
# with a different PID
|
136
|
+
second_pid = new_manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
137
|
+
expect(pid).not_to eq(second_pid)
|
138
|
+
end
|
139
|
+
|
140
|
+
def close_stream(stream, style = :inprocess)
|
141
|
+
case style
|
142
|
+
when :inprocess
|
143
|
+
stream.close
|
144
|
+
when :viahandle
|
145
|
+
handle = Pwsh::WindowsAPI.get_osfhandle(stream.fileno)
|
146
|
+
Pwsh::WindowsAPI.CloseHandle(handle)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
it 'creates a new PowerShell manager host if user code exits the first process' do
|
151
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
152
|
+
exitcode = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Kill()')[:exitcode]
|
153
|
+
|
154
|
+
# when a process gets torn down out from under manager before reading stdout
|
155
|
+
# it catches the error and returns a -1 exitcode
|
156
|
+
expect(exitcode).to eq(-1)
|
157
|
+
|
158
|
+
expect_dead_manager(manager, pipe_error_regex, :regex)
|
159
|
+
|
160
|
+
expect_different_manager_returned_than(manager, first_pid)
|
161
|
+
end
|
162
|
+
|
163
|
+
it 'creates a new PowerShell manager host if the underlying PowerShell process is killed' do
|
164
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
165
|
+
# kill the PID from Ruby
|
166
|
+
# Note - On Windows, creating the powershell manager starts one process, whereas on unix it starts two (one via sh and one via pwsh). Not sure why
|
167
|
+
# So instead kill the parent process instead of the child
|
168
|
+
Process.kill('KILL', first_pid.to_i)
|
169
|
+
|
170
|
+
# Windows uses named pipes, unix uses sockets
|
171
|
+
if Pwsh::Util.on_windows?
|
172
|
+
expect_dead_manager(manager, pipe_error_regex, :regex)
|
173
|
+
else
|
174
|
+
# WSL raises an EOFError
|
175
|
+
# Ubuntu 16.04 raises an ECONNRESET:Connection reset by peer
|
176
|
+
expect_dead_manager(manager,
|
177
|
+
[EOFError.new('end of file reached').inspect, Errno::ECONNRESET.new.inspect],
|
178
|
+
:exact)
|
179
|
+
end
|
180
|
+
|
181
|
+
expect_different_manager_returned_than(manager, first_pid)
|
182
|
+
end
|
183
|
+
|
184
|
+
context 'on Windows', if: Pwsh::Util.on_windows? do
|
185
|
+
it 'creates a new PowerShell manager host if the input stream is closed' do
|
186
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
187
|
+
|
188
|
+
# closing pipe from the Ruby side tears down the process
|
189
|
+
close_stream(manager.instance_variable_get(:@pipe), :inprocess)
|
190
|
+
|
191
|
+
expect_dead_manager(manager, IOError.new('closed stream').inspect, :exact)
|
192
|
+
|
193
|
+
expect_different_manager_returned_than(manager, first_pid)
|
194
|
+
end
|
195
|
+
|
196
|
+
it 'creates a new PowerShell manager host if the input stream handle is closed' do
|
197
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
198
|
+
|
199
|
+
# call CloseHandle against pipe, therby tearing down the PowerShell process
|
200
|
+
close_stream(manager.instance_variable_get(:@pipe), :viahandle)
|
201
|
+
|
202
|
+
expect_dead_manager(manager, bad_file_descriptor_regex, :regex)
|
203
|
+
|
204
|
+
expect_different_manager_returned_than(manager, first_pid)
|
205
|
+
end
|
206
|
+
|
207
|
+
it 'creates a new PowerShell manager host if the output stream is closed' do
|
208
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
209
|
+
|
210
|
+
# closing stdout from the Ruby side allows process to run
|
211
|
+
close_stream(manager.instance_variable_get(:@stdout), :inprocess)
|
212
|
+
|
213
|
+
# fails with vanilla EPIPE or closed stream IOError depening on timing / Ruby version
|
214
|
+
msgs = [Errno::EPIPE.new.inspect, IOError.new('closed stream').inspect]
|
215
|
+
expect_dead_manager(manager, msgs, :exact)
|
216
|
+
|
217
|
+
expect_different_manager_returned_than(manager, first_pid)
|
218
|
+
end
|
219
|
+
|
220
|
+
it 'creates a new PowerShell manager host if the output stream handle is closed' do
|
221
|
+
# currently skipped as it can trigger an internal Ruby thread clean-up race
|
222
|
+
# its unknown why this test fails, but not the identical test against @stderr
|
223
|
+
skip('This test can cause intermittent segfaults in Ruby with w32_reset_event invalid handle')
|
224
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
225
|
+
|
226
|
+
# call CloseHandle against stdout, which leaves PowerShell process running
|
227
|
+
close_stream(manager.instance_variable_get(:@stdout), :viahandle)
|
228
|
+
|
229
|
+
# fails with vanilla EPIPE or various EBADF depening on timing / Ruby version
|
230
|
+
msgs = [
|
231
|
+
"^#{Regexp.escape(Errno::EPIPE.new.inspect)}",
|
232
|
+
bad_file_descriptor_regex
|
233
|
+
]
|
234
|
+
expect_dead_manager(manager, msgs, :regex)
|
235
|
+
|
236
|
+
expect_different_manager_returned_than(manager, first_pid)
|
237
|
+
end
|
238
|
+
|
239
|
+
it 'creates a new PowerShell manager host if the error stream is closed' do
|
240
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
241
|
+
|
242
|
+
# closing stderr from the Ruby side allows process to run
|
243
|
+
close_stream(manager.instance_variable_get(:@stderr), :inprocess)
|
244
|
+
|
245
|
+
# fails with vanilla EPIPE or closed stream IOError depening on timing / Ruby version
|
246
|
+
msgs = [Errno::EPIPE.new.inspect, IOError.new('closed stream').inspect]
|
247
|
+
expect_dead_manager(manager, msgs, :exact)
|
248
|
+
|
249
|
+
expect_different_manager_returned_than(manager, first_pid)
|
250
|
+
end
|
251
|
+
|
252
|
+
it 'creates a new PowerShell manager host if the error stream handle is closed' do
|
253
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
254
|
+
|
255
|
+
# call CloseHandle against stderr, which leaves PowerShell process running
|
256
|
+
close_stream(manager.instance_variable_get(:@stderr), :viahandle)
|
257
|
+
|
258
|
+
# fails with vanilla EPIPE or various EBADF depening on timing / Ruby version
|
259
|
+
msgs = [
|
260
|
+
"^#{Regexp.escape(Errno::EPIPE.new.inspect)}",
|
261
|
+
bad_file_descriptor_regex
|
262
|
+
]
|
263
|
+
expect_dead_manager(manager, msgs, :regex)
|
264
|
+
|
265
|
+
expect_different_manager_returned_than(manager, first_pid)
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
describe 'when provided powershell commands' do
|
272
|
+
it 'shows ps version' do
|
273
|
+
result = manager.execute('$psversiontable')
|
274
|
+
puts result[:stdout]
|
275
|
+
end
|
276
|
+
|
277
|
+
it 'returns simple output' do
|
278
|
+
result = manager.execute('write-output foo')
|
279
|
+
|
280
|
+
expect(result[:stdout]).to eq("foo#{line_end}")
|
281
|
+
expect(result[:exitcode]).to eq(0)
|
282
|
+
end
|
283
|
+
|
284
|
+
it 'returns the exitcode specified' do
|
285
|
+
result = manager.execute('write-output foo; exit 55')
|
286
|
+
|
287
|
+
expect(result[:stdout]).to eq("foo#{line_end}")
|
288
|
+
expect(result[:exitcode]).to eq(55)
|
289
|
+
end
|
290
|
+
|
291
|
+
it 'returns the exitcode 1 when exception is thrown' do
|
292
|
+
result = manager.execute('throw "foo"')
|
293
|
+
|
294
|
+
expect(result[:stdout]).to be_nil
|
295
|
+
expect(result[:exitcode]).to eq(1)
|
296
|
+
end
|
297
|
+
|
298
|
+
it 'returns the exitcode of the last command to set an exit code' do
|
299
|
+
result = if Pwsh::Util.on_windows?
|
300
|
+
manager.execute("$LASTEXITCODE = 0; write-output 'foo'; cmd.exe /c 'exit 99'; write-output 'bar'")
|
301
|
+
else
|
302
|
+
manager.execute("$LASTEXITCODE = 0; write-output 'foo'; /bin/sh -c 'exit 99'; write-output 'bar'")
|
303
|
+
end
|
304
|
+
|
305
|
+
expect(result[:stdout]).to eq("foo#{line_end}bar#{line_end}")
|
306
|
+
expect(result[:exitcode]).to eq(99)
|
307
|
+
end
|
308
|
+
|
309
|
+
it 'returns the exitcode of a script invoked with the call operator &' do
|
310
|
+
fixture_path = File.expand_path("#{File.dirname(__FILE__)}/../exit-27.ps1")
|
311
|
+
result = manager.execute("& #{fixture_path}")
|
312
|
+
|
313
|
+
expect(result[:stdout]).to be_nil
|
314
|
+
expect(result[:exitcode]).to eq(27)
|
315
|
+
end
|
316
|
+
|
317
|
+
it 'collects anything written to stderr' do
|
318
|
+
result = manager.execute('[System.Console]::Error.WriteLine("foo")')
|
319
|
+
|
320
|
+
expect(result[:stderr]).to eq(["foo#{line_end}"])
|
321
|
+
expect(result[:exitcode]).to eq(0)
|
322
|
+
end
|
323
|
+
|
324
|
+
it 'collects multiline output written to stderr' do
|
325
|
+
# induce a failure in cmd.exe that emits a multi-iline error message
|
326
|
+
result = if Pwsh::Util.on_windows?
|
327
|
+
manager.execute('cmd.exe /c foo.exe')
|
328
|
+
else
|
329
|
+
manager.execute('/bin/sh -c "echo bar 1>&2 && foo.exe"')
|
330
|
+
end
|
331
|
+
|
332
|
+
expect(result[:stdout]).to be_nil
|
333
|
+
if Pwsh::Util.on_windows?
|
334
|
+
expect(result[:stderr]).to eq(["'foo.exe' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n"])
|
335
|
+
else
|
336
|
+
expect(result[:stderr][0]).to match(/foo\.exe:(?:.*)not found/)
|
337
|
+
expect(result[:stderr][0]).to match(/bar/)
|
338
|
+
end
|
339
|
+
expect(result[:exitcode]).not_to eq(0)
|
340
|
+
end
|
341
|
+
|
342
|
+
it 'handles writting to stdout (cmdlet) and stderr' do
|
343
|
+
result = manager.execute('Write-Host "powershell";[System.Console]::Error.WriteLine("foo")')
|
344
|
+
|
345
|
+
expect(result[:stdout]).not_to be_nil
|
346
|
+
expect(result[:native_stdout]).to be_nil
|
347
|
+
expect(result[:stderr]).to eq(["foo#{line_end}"])
|
348
|
+
expect(result[:exitcode]).to eq(0)
|
349
|
+
end
|
350
|
+
|
351
|
+
it 'handles writting to stdout (shell out to another program) and stderr' do
|
352
|
+
result = if Pwsh::Util.on_windows?
|
353
|
+
manager.execute('cmd.exe /c echo powershell;[System.Console]::Error.WriteLine("foo")')
|
354
|
+
else
|
355
|
+
manager.execute('/bin/sh -c "echo powershell";[System.Console]::Error.WriteLine("foo")')
|
356
|
+
end
|
357
|
+
|
358
|
+
expect(result[:stdout]).to be_nil
|
359
|
+
expect(result[:native_stdout]).not_to be_nil
|
360
|
+
expect(result[:stderr]).to eq(["foo#{line_end}"])
|
361
|
+
expect(result[:exitcode]).to eq(0)
|
362
|
+
end
|
363
|
+
|
364
|
+
it 'handles writing to stdout natively' do
|
365
|
+
result = manager.execute('[System.Console]::Out.WriteLine("foo")')
|
366
|
+
|
367
|
+
expect(result[:stdout]).to eq("foo#{line_end}")
|
368
|
+
expect(result[:native_stdout]).to be_nil
|
369
|
+
expect(result[:stderr]).to eq([])
|
370
|
+
expect(result[:exitcode]).to eq(0)
|
371
|
+
end
|
372
|
+
|
373
|
+
it 'properly interleaves output written natively to stdout and via Write-XXX cmdlets' do
|
374
|
+
result = manager.execute('Write-Output "bar"; [System.Console]::Out.WriteLine("foo"); Write-Warning "baz";')
|
375
|
+
|
376
|
+
expect(result[:stdout]).to eq("bar#{line_end}foo#{line_end}WARNING: baz#{line_end}")
|
377
|
+
expect(result[:stderr]).to eq([])
|
378
|
+
expect(result[:exitcode]).to eq(0)
|
379
|
+
end
|
380
|
+
|
381
|
+
it 'handles writing to regularly captured output AND stdout natively' do
|
382
|
+
result = manager.execute('Write-Host "powershell";[System.Console]::Out.WriteLine("foo")')
|
383
|
+
|
384
|
+
expect(result[:stdout]).not_to eq("foo#{line_end}")
|
385
|
+
expect(result[:native_stdout]).to be_nil
|
386
|
+
expect(result[:stderr]).to eq([])
|
387
|
+
expect(result[:exitcode]).to eq(0)
|
388
|
+
end
|
389
|
+
|
390
|
+
it 'handles writing to regularly captured output, stderr AND stdout natively' do
|
391
|
+
result = manager.execute('Write-Host "powershell";[System.Console]::Out.WriteLine("foo");[System.Console]::Error.WriteLine("bar")')
|
392
|
+
|
393
|
+
expect(result[:stdout]).not_to eq("foo#{line_end}")
|
394
|
+
expect(result[:native_stdout]).to be_nil
|
395
|
+
expect(result[:stderr]).to eq(["bar#{line_end}"])
|
396
|
+
expect(result[:exitcode]).to eq(0)
|
397
|
+
end
|
398
|
+
|
399
|
+
context 'it should handle UTF-8' do
|
400
|
+
# different UTF-8 widths
|
401
|
+
# 1-byte A
|
402
|
+
# 2-byte ۿ - http://www.fileformat.info/info/unicode/char/06ff/index.htm - 0xDB 0xBF / 219 191
|
403
|
+
# 3-byte ᚠ - http://www.fileformat.info/info/unicode/char/16A0/index.htm - 0xE1 0x9A 0xA0 / 225 154 160
|
404
|
+
# 4-byte 𠜎 - http://www.fileformat.info/info/unicode/char/2070E/index.htm - 0xF0 0xA0 0x9C 0x8E / 240 160 156 142
|
405
|
+
let(:mixed_utf8) { "A\u06FF\u16A0\u{2070E}" } # Aۿᚠ𠜎
|
406
|
+
|
407
|
+
it 'when writing basic text' do
|
408
|
+
code = "Write-Output '#{mixed_utf8}'"
|
409
|
+
result = manager.execute(code)
|
410
|
+
|
411
|
+
expect(result[:stdout]).to eq("#{mixed_utf8}#{line_end}")
|
412
|
+
expect(result[:exitcode]).to eq(0)
|
413
|
+
end
|
414
|
+
|
415
|
+
it 'when writing basic text to stderr' do
|
416
|
+
code = "[System.Console]::Error.WriteLine('#{mixed_utf8}')"
|
417
|
+
result = manager.execute(code)
|
418
|
+
|
419
|
+
expect(result[:stderr]).to eq(["#{mixed_utf8}#{line_end}"])
|
420
|
+
expect(result[:exitcode]).to eq(0)
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
it 'executes cmdlets' do
|
425
|
+
result = manager.execute('Get-Verb')
|
426
|
+
|
427
|
+
expect(result[:stdout]).not_to be_nil
|
428
|
+
expect(result[:exitcode]).to eq(0)
|
429
|
+
end
|
430
|
+
|
431
|
+
it 'executes cmdlets with pipes' do
|
432
|
+
result = manager.execute('Get-Process | ? { $_.PID -ne $PID }')
|
433
|
+
|
434
|
+
expect(result[:stdout]).not_to be_nil
|
435
|
+
expect(result[:exitcode]).to eq(0)
|
436
|
+
end
|
437
|
+
|
438
|
+
it 'executes multi-line' do
|
439
|
+
result = manager.execute(<<-CODE
|
440
|
+
$foo = ls
|
441
|
+
$count = $foo.count
|
442
|
+
$count
|
443
|
+
CODE
|
444
|
+
)
|
445
|
+
|
446
|
+
expect(result[:stdout]).not_to be_nil
|
447
|
+
expect(result[:exitcode]).to eq(0)
|
448
|
+
end
|
449
|
+
|
450
|
+
it 'executes code with a try/catch, receiving the output of Write-Error' do
|
451
|
+
result = manager.execute(<<-CODE
|
452
|
+
try{
|
453
|
+
$foo = ls
|
454
|
+
$count = $foo.count
|
455
|
+
$count
|
456
|
+
}catch{
|
457
|
+
Write-Error "foo"
|
458
|
+
}
|
459
|
+
CODE
|
460
|
+
)
|
461
|
+
|
462
|
+
expect(result[:stdout]).not_to be_nil
|
463
|
+
expect(result[:exitcode]).to eq(0)
|
464
|
+
end
|
465
|
+
|
466
|
+
it 'is able to execute the code in a try block when using try/catch' do
|
467
|
+
result = manager.execute(<<-CODE
|
468
|
+
try {
|
469
|
+
$foo = @(1, 2, 3).count
|
470
|
+
exit 400
|
471
|
+
} catch {
|
472
|
+
exit 1
|
473
|
+
}
|
474
|
+
CODE
|
475
|
+
)
|
476
|
+
|
477
|
+
expect(result[:stdout]).to be_nil
|
478
|
+
# using an explicit exit code ensures we've really executed correct block
|
479
|
+
expect(result[:exitcode]).to eq(400)
|
480
|
+
end
|
481
|
+
|
482
|
+
it 'is able to execute the code in a catch block when using try/catch' do
|
483
|
+
result = manager.execute(<<-CODE
|
484
|
+
try {
|
485
|
+
throw "Error!"
|
486
|
+
exit 0
|
487
|
+
} catch {
|
488
|
+
exit 500
|
489
|
+
}
|
490
|
+
CODE
|
491
|
+
)
|
492
|
+
|
493
|
+
expect(result[:stdout]).to be_nil
|
494
|
+
# using an explicit exit code ensures we've really executed correct block
|
495
|
+
expect(result[:exitcode]).to eq(500)
|
496
|
+
end
|
497
|
+
|
498
|
+
it 'reuses the same PowerShell process for multiple calls' do
|
499
|
+
first_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
500
|
+
second_pid = manager.execute('[Diagnostics.Process]::GetCurrentProcess().Id')[:stdout]
|
501
|
+
|
502
|
+
expect(first_pid).to eq(second_pid)
|
503
|
+
end
|
504
|
+
|
505
|
+
it 'removes psvariables between runs' do
|
506
|
+
manager.execute('$foo = "bar"')
|
507
|
+
result = manager.execute('$foo')
|
508
|
+
|
509
|
+
expect(result[:stdout]).to be_nil
|
510
|
+
end
|
511
|
+
|
512
|
+
it 'removes env variables between runs' do
|
513
|
+
manager.execute('[Environment]::SetEnvironmentVariable("foo", "bar", "process")')
|
514
|
+
result = manager.execute('Test-Path env:\foo')
|
515
|
+
|
516
|
+
expect(result[:stdout]).to eq("False#{line_end}")
|
517
|
+
end
|
518
|
+
|
519
|
+
it 'sets custom environment variables' do
|
520
|
+
result = manager.execute('Write-Output $ENV:foo', nil, nil, ['foo=bar'])
|
521
|
+
|
522
|
+
expect(result[:stdout]).to eq("bar#{line_end}")
|
523
|
+
end
|
524
|
+
|
525
|
+
it 'removes custom environment variables between runs' do
|
526
|
+
manager.execute('Write-Output $ENV:foo', nil, nil, ['foo=bar'])
|
527
|
+
result = manager.execute('Write-Output $ENV:foo', nil, nil, [])
|
528
|
+
|
529
|
+
expect(result[:stdout]).to be_nil
|
530
|
+
end
|
531
|
+
|
532
|
+
it 'ignores malformed custom environment variable' do
|
533
|
+
result = manager.execute('Write-Output $ENV:foo', nil, nil, ['=foo', 'foo', 'foo='])
|
534
|
+
|
535
|
+
expect(result[:stdout]).to be_nil
|
536
|
+
end
|
537
|
+
|
538
|
+
it 'uses last definition for duplicate custom environment variable' do
|
539
|
+
result = manager.execute('Write-Output $ENV:foo', nil, nil, ['foo=one', 'foo=two', 'foo=three'])
|
540
|
+
|
541
|
+
expect(result[:stdout]).to eq("three#{line_end}")
|
542
|
+
end
|
543
|
+
|
544
|
+
def current_powershell_major_version(ps_command, ps_args)
|
545
|
+
# As this is only used to detect old PS versions we can
|
546
|
+
# short circuit detecting the version for PowerShell Core
|
547
|
+
return 6 if ps_command.end_with?('pwsh', 'pwsh.exe')
|
548
|
+
|
549
|
+
begin
|
550
|
+
version = `#{ps_command} #{ps_args.join(' ')} -Command \"$PSVersionTable.PSVersion.Major.ToString()\"`.chomp!.to_i
|
551
|
+
rescue StandardError
|
552
|
+
puts 'Unable to determine PowerShell version'
|
553
|
+
version = -1
|
554
|
+
end
|
555
|
+
|
556
|
+
version
|
557
|
+
end
|
558
|
+
|
559
|
+
def output_cmdlet(ps_command, ps_args)
|
560
|
+
# Write-Output is the default behavior, except on older PS2 where the
|
561
|
+
# behavior of Write-Output introduces newlines after every width number
|
562
|
+
# of characters as specified in the BufferSize of the custom console UI
|
563
|
+
# Write-Host should usually be avoided, but works for this test in old PS2
|
564
|
+
current_powershell_major_version(ps_command, ps_args) >= 3 ? 'Write-Output' : 'Write-Host'
|
565
|
+
end
|
566
|
+
|
567
|
+
it 'is be able to write more than the 64k default buffer size to the managers pipe without deadlocking the Ruby parent process or breaking the pipe' do
|
568
|
+
# this was tested successfully up to 5MB of text
|
569
|
+
# we add some additional bytes so it's not always on a 1KB boundary and forces pipe reading in different lengths, not always 1K chunks
|
570
|
+
buffer_string_96k = 'a' * ((1024 * 96) + 11)
|
571
|
+
result = manager.execute(<<-CODE
|
572
|
+
'#{buffer_string_96k}' | #{output_cmdlet(ps_command, ps_args)}
|
573
|
+
CODE
|
574
|
+
)
|
575
|
+
|
576
|
+
expect(result[:errormessage]).to be_nil
|
577
|
+
expect(result[:exitcode]).to eq(0)
|
578
|
+
expect(result[:stdout].length).to eq("#{buffer_string_96k}#{line_end}".length)
|
579
|
+
expect(result[:stdout]).to eq("#{buffer_string_96k}#{line_end}")
|
580
|
+
end
|
581
|
+
|
582
|
+
it 'is be able to write more than the 64k default buffer size to child process stdout without deadlocking the Ruby parent process' do
|
583
|
+
# we add some additional bytes so it's not always on a 1KB boundary and forces pipe reading in different lengths, not always 1K chunks
|
584
|
+
result = manager.execute(<<-CODE
|
585
|
+
$bytes_in_k = (1024 * 64) + 11
|
586
|
+
[Text.Encoding]::UTF8.GetString((New-Object Byte[] ($bytes_in_k))) | #{output_cmdlet(ps_command, ps_args)}
|
587
|
+
CODE
|
588
|
+
)
|
589
|
+
|
590
|
+
expect(result[:errormessage]).to be_nil
|
591
|
+
expect(result[:exitcode]).to eq(0)
|
592
|
+
expected = ("\x0" * ((1024 * 64) + 11)) + line_end
|
593
|
+
expect(result[:stdout].length).to eq(expected.length)
|
594
|
+
expect(result[:stdout]).to eq(expected)
|
595
|
+
end
|
596
|
+
|
597
|
+
it 'returns a response with a timeout error if the execution timeout is exceeded' do
|
598
|
+
timeout_ms = 100
|
599
|
+
result = manager.execute('sleep 1', timeout_ms)
|
600
|
+
msg = /Catastrophic failure: PowerShell module timeout \(#{timeout_ms} ms\) exceeded while executing/
|
601
|
+
expect(result[:errormessage]).to match(msg)
|
602
|
+
end
|
603
|
+
|
604
|
+
it 'returns any available stdout / stderr prior to being terminated if a timeout error occurs' do
|
605
|
+
timeout_ms = 1500
|
606
|
+
command = '$debugPreference = "Continue"; $ErrorView = "NormalView" ; Write-Output "200 OK Glenn"; Write-Debug "304 Not Modified James"; Write-Error "404 Craig Not Found"; sleep 10'
|
607
|
+
result = manager.execute(command, timeout_ms)
|
608
|
+
expect(result[:exitcode]).to eq(1)
|
609
|
+
# starts with Write-Output and Write-Debug messages
|
610
|
+
expect(result[:stdout]).to match(/200 OK Glenn/)
|
611
|
+
expect(result[:stdout]).to match(/DEBUG: 304 Not Modified James/)
|
612
|
+
# then command may have \r\n injected, so remove those for comparison
|
613
|
+
expect(result[:stdout].gsub(/\r\n/, '')).to include(command)
|
614
|
+
# and it should end with the Write-Error content
|
615
|
+
expect(result[:stdout]).to match(/404 Craig Not Found/)
|
616
|
+
end
|
617
|
+
|
618
|
+
it 'uses a default timeout of 300 seconds if the user specified a timeout of 0' do
|
619
|
+
timeout_ms = 0
|
620
|
+
command = 'return $true'
|
621
|
+
code = manager.make_ps_code(command, timeout_ms)
|
622
|
+
expect(code).to match(/TimeoutMilliseconds\s+=\s+300000/)
|
623
|
+
end
|
624
|
+
|
625
|
+
it 'uses the correct correct timeout if a small value is specified' do
|
626
|
+
# Zero timeout is not supported, and a timeout less than 50ms is not supported.
|
627
|
+
# This test is to ensure that the code that inserts the default timeout when
|
628
|
+
# the user specified zero, does not interfere with the other default of 50ms
|
629
|
+
# if the user specifies a value less than that.
|
630
|
+
|
631
|
+
timeout_ms = 20
|
632
|
+
command = 'return $true'
|
633
|
+
code = manager.make_ps_code(command, timeout_ms)
|
634
|
+
expect(code).to match(/TimeoutMilliseconds\s+=\s+50/)
|
635
|
+
end
|
636
|
+
|
637
|
+
it 'does not deadlock and returns a valid response given invalid unparseable PowerShell code' do
|
638
|
+
result = manager.execute(<<-CODE
|
639
|
+
{
|
640
|
+
|
641
|
+
CODE
|
642
|
+
)
|
643
|
+
|
644
|
+
expect(result[:errormessage]).not_to be_empty
|
645
|
+
end
|
646
|
+
|
647
|
+
it 'errors if working directory does not exist' do
|
648
|
+
work_dir = 'C:/some/directory/that/does/not/exist'
|
649
|
+
|
650
|
+
result = manager.execute('(Get-Location).Path', nil, work_dir)
|
651
|
+
|
652
|
+
expect(result[:exitcode]).not_to eq(0)
|
653
|
+
expect(result[:errormessage]).to match(/Working directory .+ does not exist/)
|
654
|
+
end
|
655
|
+
|
656
|
+
it 'allows forward slashes in working directory', if: Pwsh::Util.on_windows? do
|
657
|
+
# Backslashes only apply on Windows filesystems
|
658
|
+
work_dir = ENV.fetch('WINDIR', nil)
|
659
|
+
forward_work_dir = work_dir.tr('\\', '/')
|
660
|
+
|
661
|
+
result = manager.execute('(Get-Location).Path', nil, forward_work_dir)[:stdout]
|
662
|
+
|
663
|
+
expect(result).to match(/#{Regexp.escape(work_dir)}/i)
|
664
|
+
end
|
665
|
+
|
666
|
+
it 'uses a specific working directory if set' do
|
667
|
+
work_dir = Pwsh::Util.on_windows? ? ENV.fetch('WINDIR', nil) : Dir.home
|
668
|
+
|
669
|
+
result = manager.execute('(Get-Location).Path', nil, work_dir)[:stdout]
|
670
|
+
|
671
|
+
expect(result).to match(/#{Regexp.escape(work_dir)}/i)
|
672
|
+
end
|
673
|
+
|
674
|
+
it 'does not reuse the same working directory between runs' do
|
675
|
+
work_dir = Pwsh::Util.on_windows? ? ENV.fetch('WINDIR', nil) : Dir.home
|
676
|
+
current_work_dir = Pwsh::Util.on_windows? ? Dir.getwd.tr('/', '\\') : Dir.getwd
|
677
|
+
|
678
|
+
first_cwd = manager.execute('(Get-Location).Path', nil, work_dir)[:stdout]
|
679
|
+
second_cwd = manager.execute('(Get-Location).Path')[:stdout]
|
680
|
+
|
681
|
+
# Paths should be case insensitive
|
682
|
+
expect(first_cwd.downcase).to eq("#{work_dir}#{line_end}".downcase)
|
683
|
+
expect(second_cwd.downcase).to eq("#{current_work_dir}#{line_end}".downcase)
|
684
|
+
end
|
685
|
+
|
686
|
+
context 'with runtime error' do
|
687
|
+
it "does not refer to 'EndInvoke' or 'throw' for a runtime error" do
|
688
|
+
result = manager.execute(powershell_runtime_error)
|
689
|
+
|
690
|
+
expect(result[:exitcode]).to eq(1)
|
691
|
+
expect(result[:errormessage]).not_to match(/EndInvoke/)
|
692
|
+
expect(result[:errormessage]).not_to match(/throw/)
|
693
|
+
end
|
694
|
+
|
695
|
+
it 'displays line and char information for a runtime error' do
|
696
|
+
result = manager.execute(powershell_runtime_error)
|
697
|
+
|
698
|
+
expect(result[:exitcode]).to eq(1)
|
699
|
+
expect(result[:errormessage]).to match(/At line:\d+ char:\d+/)
|
700
|
+
end
|
701
|
+
end
|
702
|
+
|
703
|
+
context 'with ParseException error' do
|
704
|
+
it "does not refer to 'EndInvoke' or 'throw' for a ParseException error" do
|
705
|
+
result = manager.execute(powershell_parseexception_error)
|
706
|
+
|
707
|
+
expect(result[:exitcode]).to eq(1)
|
708
|
+
expect(result[:errormessage]).not_to match(/EndInvoke/)
|
709
|
+
expect(result[:errormessage]).not_to match(/throw/)
|
710
|
+
end
|
711
|
+
|
712
|
+
it 'displays line and char information for a ParseException error' do
|
713
|
+
result = manager.execute(powershell_parseexception_error)
|
714
|
+
|
715
|
+
expect(result[:exitcode]).to eq(1)
|
716
|
+
expect(result[:errormessage]).to match(/At line:\d+ char:\d+/)
|
717
|
+
end
|
718
|
+
end
|
719
|
+
|
720
|
+
context 'with IncompleteParseException error' do
|
721
|
+
it "does not refer to 'EndInvoke' or 'throw' for an IncompleteParseException error" do
|
722
|
+
result = manager.execute(powershell_incompleteparseexception_error)
|
723
|
+
|
724
|
+
expect(result[:exitcode]).to eq(1)
|
725
|
+
expect(result[:errormessage]).not_to match(/EndInvoke/)
|
726
|
+
expect(result[:errormessage]).not_to match(/throw/)
|
727
|
+
end
|
728
|
+
|
729
|
+
it 'does not display line and char information for an IncompleteParseException error' do
|
730
|
+
result = manager.execute(powershell_incompleteparseexception_error)
|
731
|
+
|
732
|
+
expect(result[:exitcode]).to eq(1)
|
733
|
+
expect(result[:errormessage]).not_to match(/At line:\d+ char:\d+/)
|
734
|
+
end
|
735
|
+
end
|
736
|
+
end
|
737
|
+
|
738
|
+
describe 'when output is written to a PowerShell Stream' do
|
739
|
+
it 'collects anything written to verbose stream' do
|
740
|
+
msg = SecureRandom.uuid.to_s.delete('-')
|
741
|
+
result = manager.execute("$VerbosePreference = 'Continue';Write-Verbose '#{msg}'")
|
742
|
+
|
743
|
+
expect(result[:stdout]).to match(/^VERBOSE: #{msg}/)
|
744
|
+
expect(result[:exitcode]).to eq(0)
|
745
|
+
end
|
746
|
+
|
747
|
+
it 'collects anything written to debug stream' do
|
748
|
+
msg = SecureRandom.uuid.to_s.delete('-')
|
749
|
+
result = manager.execute("$debugPreference = 'Continue';Write-debug '#{msg}'")
|
750
|
+
|
751
|
+
expect(result[:stdout]).to match(/^DEBUG: #{msg}/)
|
752
|
+
expect(result[:exitcode]).to eq(0)
|
753
|
+
end
|
754
|
+
|
755
|
+
it 'collects anything written to Warning stream' do
|
756
|
+
msg = SecureRandom.uuid.to_s.delete('-')
|
757
|
+
result = manager.execute("Write-Warning '#{msg}'")
|
758
|
+
|
759
|
+
expect(result[:stdout]).to match(/^WARNING: #{msg}/)
|
760
|
+
expect(result[:exitcode]).to eq(0)
|
761
|
+
end
|
762
|
+
|
763
|
+
it 'collects anything written to Error stream' do
|
764
|
+
msg = SecureRandom.uuid.to_s.delete('-')
|
765
|
+
result = manager.execute("$ErrorView = 'NormalView' ; Write-Error '#{msg}'")
|
766
|
+
|
767
|
+
expect(result[:stdout]).to match(/Write-Error '#{msg}' : #{msg}/)
|
768
|
+
expect(result[:exitcode]).to eq(0)
|
769
|
+
end
|
770
|
+
|
771
|
+
it 'handles a Write-Error in the middle of code' do
|
772
|
+
result = manager.execute('Write-Host "one" ;Write-Error "Hello"; Write-Host "two"')
|
773
|
+
|
774
|
+
expect(result[:stdout]).not_to be_nil
|
775
|
+
expect(result[:exitcode]).to eq(0)
|
776
|
+
end
|
777
|
+
|
778
|
+
it 'handles a Out-Default in the user code' do
|
779
|
+
result = manager.execute('\'foo\' | Out-Default')
|
780
|
+
|
781
|
+
expect(result[:stdout]).to eq("foo#{line_end}")
|
782
|
+
expect(result[:exitcode]).to eq(0)
|
783
|
+
end
|
784
|
+
|
785
|
+
it 'handles lots of output from user code' do
|
786
|
+
result = manager.execute('1..1000 | %{ (65..90) + (97..122) | Get-Random -Count 5 | % {[char]$_} }')
|
787
|
+
|
788
|
+
expect(result[:stdout]).not_to be_nil
|
789
|
+
expect(result[:exitcode]).to eq(0)
|
790
|
+
end
|
791
|
+
|
792
|
+
it 'handles a larger return of output from user code' do
|
793
|
+
result = manager.execute('1..1000 | %{ (65..90) + (97..122) | Get-Random -Count 5 | % {[char]$_} } | %{ $f="" } { $f+=$_ } {$f }')
|
794
|
+
|
795
|
+
expect(result[:stdout]).not_to be_nil
|
796
|
+
expect(result[:exitcode]).to eq(0)
|
797
|
+
end
|
798
|
+
|
799
|
+
it 'handles shell redirection' do
|
800
|
+
# the test here is to ensure that this doesn't break. because we merge the streams regardless
|
801
|
+
# the opposite of this test shows the same thing
|
802
|
+
result = manager.execute('function test-error{ ps;write-error \'foo\' }; test-error 2>&1')
|
803
|
+
|
804
|
+
expect(result[:stdout]).not_to be_nil
|
805
|
+
expect(result[:exitcode]).to eq(0)
|
806
|
+
end
|
807
|
+
end
|
808
|
+
end
|
809
|
+
end
|
810
|
+
|
811
|
+
RSpec.describe 'On Windows PowerShell', if: Pwsh::Util.on_windows? && Pwsh::Manager.windows_powershell_supported? do
|
812
|
+
it_behaves_like 'a PowerShellCodeManager',
|
813
|
+
Pwsh::Manager.powershell_path,
|
814
|
+
Pwsh::Manager.powershell_args
|
815
|
+
end
|
816
|
+
|
817
|
+
RSpec.describe 'On PowerShell Core', if: Pwsh::Manager.pwsh_supported? do
|
818
|
+
it_behaves_like 'a PowerShellCodeManager',
|
819
|
+
Pwsh::Manager.pwsh_path,
|
820
|
+
Pwsh::Manager.pwsh_args
|
821
|
+
end
|