winrm-transport 1.0.2 → 1.0.3

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.
@@ -1,498 +1,498 @@
1
- # -*- encoding: utf-8 -*-
2
- #
3
- # Author:: Fletcher (<fnichol@nichol.ca>)
4
- #
5
- # Copyright (C) 2015, Fletcher Nichol
6
- #
7
- # Licensed under the Apache License, Version 2.0 (the "License");
8
- # you may not use this file except in compliance with the License.
9
- # You may obtain a copy of the License at
10
- #
11
- # http://www.apache.org/licenses/LICENSE-2.0
12
- #
13
- # Unless required by applicable law or agreed to in writing, software
14
- # distributed under the License is distributed on an "AS IS" BASIS,
15
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
- # See the License for the specific language governing permissions and
17
- # limitations under the License.
18
-
19
- require "benchmark"
20
- require "csv"
21
- require "digest"
22
- require "securerandom"
23
- require "stringio"
24
-
25
- require "winrm/transport/logging"
26
- require "winrm/transport/tmp_zip"
27
-
28
- module WinRM
29
-
30
- module Transport
31
-
32
- # Wrapped exception for any internally raised WinRM-related errors.
33
- #
34
- # @author Fletcher Nichol <fnichol@nichol.ca>
35
- class FileTransporterFailed < ::WinRM::WinRMError; end
36
-
37
- # Object which can upload one or more files or directories to a remote
38
- # host over WinRM using PowerShell scripts and CMD commands. Note that
39
- # this form of file transfer is *not* ideal and extremely costly on both
40
- # the local and remote sides. Great pains are made to minimize round
41
- # trips to the remote host and to minimize the number of PowerShell
42
- # sessions being invoked which can be 2 orders of magnitude more
43
- # expensive than vanilla CMD commands.
44
- #
45
- # This object is supported by either a `WinRM::WinRMWebService` or
46
- # `CommandExecutor` instance as it depends on the `#run_cmd` and
47
- # `#run_powershell_script` API contracts.
48
- #
49
- # An optional logger can be supplied, assuming it can respond to the
50
- # `#debug` and `#debug?` messages.
51
- #
52
- # @author Fletcher Nichol <fnichol@nichol.ca>
53
- # @author Matt Wrock <matt@mattwrock.com>
54
- class FileTransporter
55
-
56
- include Logging
57
-
58
- # Creates a FileTransporter given a service object and optional logger.
59
- # The service object may be a `WinRM::WinRMWebService` or
60
- # `CommandExecutor` instance.
61
- #
62
- # @param service [WinRM::WinRMWebService,CommandExecutor] a
63
- # winrm web service object
64
- # @param logger [#debug,#debug?] an optional logger/ui object that
65
- # responds to `#debug` and `#debug?` (default: `nil`)
66
- def initialize(service, logger = nil, opts = {})
67
- @service = service
68
- @logger = logger
69
- @id_generator = opts.fetch(:id_generator) { -> { SecureRandom.uuid } }
70
- end
71
-
72
- # Uploads a collection of files and/or directories to the remote host.
73
- #
74
- # **TODO Notes:**
75
- # * options could specify zip mode, zip options, etc.
76
- # * maybe option to set tmpfile base dir to override $env:PATH?
77
- # * progress yields block like net-scp progress
78
- # * final API: def upload(locals, remote, _options = {}, &_progress)
79
- #
80
- # @param locals [Array<String>,String] one or more local file or
81
- # directory paths
82
- # @param remote [String] the base destination path on the remote host
83
- # @return [Hash] report hash, keyed by the local MD5 digest
84
- def upload(locals, remote)
85
- files = nil
86
-
87
- elapsed = Benchmark.measure do
88
- files = make_files_hash(Array(locals), remote)
89
-
90
- report = check_files(files)
91
- merge_with_report!(files, report)
92
-
93
- report = stream_upload_files(files)
94
- merge_with_report!(files, report)
95
-
96
- report = decode_files(files)
97
- merge_with_report!(files, report)
98
-
99
- cleanup(files)
100
- end
101
-
102
- debug {
103
- "Uploaded #{files.keys.size} items " \
104
- "in #{duration(elapsed.real)}"
105
- }
106
-
107
- files
108
- end
109
-
110
- private
111
-
112
- # @return [Integer] the maximum number of bytes that can be supplied on
113
- # a Windows CMD prompt without exceeded the maximum command line
114
- # length
115
- # @api private
116
- MAX_ENCODED_WRITE = 8000
117
-
118
- # @return [String] the Array pack template for Base64 encoding a stream
119
- # of data
120
- # @api private
121
- BASE64_PACK = "m0".freeze
122
-
123
- # @return [#debug,#debug?] the logger
124
- # @api private
125
- attr_reader :logger
126
-
127
- # @return [WinRM::WinRMWebService,Winrm::CommandExecutor] a WinRM web
128
- # service object
129
- # @api private
130
- attr_reader :service
131
-
132
- # Adds an entry to a files Hash (keyed by local MD5 digest) for a
133
- # directory. When a directory is added, a temporary Zip file is created
134
- # containing the contents of the directory and any file-related data
135
- # such as MD5 digest, size, etc. will be referring to the Zip file.
136
- #
137
- # @param hash [Hash] hash to be mutated
138
- # @param dir [String] directory path to be Zipped and added
139
- # @param remote [String] path to destination on remote host
140
- # @api private
141
- def add_directory_hash!(hash, dir, remote)
142
- zip_io = TmpZip.new(dir, logger)
143
- zip_md5 = md5sum(zip_io.path)
144
-
145
- hash[zip_md5] = {
146
- "src" => dir,
147
- "src_zip" => zip_io.path.to_s,
148
- "zip_io" => zip_io,
149
- "tmpzip" => "$env:TEMP\\tmpzip-#{zip_md5}.zip",
150
- "dst" => remote,
151
- "size" => File.size(zip_io.path)
152
- }
153
- end
154
-
155
- # Adds an entry to a files Hash (keyed by local MD5 digest) for a file.
156
- #
157
- # @param hash [Hash] hash to be mutated
158
- # @param local [String] file path
159
- # @param remote [String] path to destination on remote host
160
- # @api private
161
- def add_file_hash!(hash, local, remote)
162
- hash[md5sum(local)] = {
163
- "src" => local,
164
- "dst" => "#{remote}\\#{File.basename(local)}",
165
- "size" => File.size(local)
166
- }
167
- end
168
-
169
- # Runs the check_files PowerShell script against a collection of
170
- # destination path/MD5 checksum pairs. The PowerShell script returns
171
- # its results as a CSV-formatted report which is converted into a Ruby
172
- # Hash.
173
- #
174
- # @param files [Hash] files hash, keyed by the local MD5 digest
175
- # @return [Hash] a report hash, keyed by the local MD5 digest
176
- # @api private
177
- def check_files(files)
178
- debug { "Running check_files.ps1" }
179
- hash_file = create_remote_hash_file(check_files_ps_hash(files))
180
- vars = %{$hash_file = "#{hash_file}"\n}
181
- output = service.run_powershell_script(
182
- [vars, check_files_script].join("\n")
183
- )
184
- parse_response(output)
185
- end
186
-
187
- # Constructs a collection of destination path/MD5 checksum pairs as a
188
- # String representation of the contents of a PowerShell Hash Table.
189
- #
190
- # @param files [Hash] files hash, keyed by the local MD5 digest
191
- # @return [String] the inner contents of a PowerShell Hash Table
192
- # @api private
193
- def check_files_ps_hash(files)
194
- ps_hash(Hash[
195
- files.map { |md5, data| [data.fetch("tmpzip", data["dst"]), md5] }
196
- ])
197
- end
198
-
199
- # @return [String] the check_files PowerShell script
200
- # @api private
201
- def check_files_script
202
- @check_files_script ||= IO.read(File.join(
203
- File.dirname(__FILE__), %W[.. .. .. support check_files.ps1]
204
- ))
205
- end
206
-
207
- # Performs any final cleanup on the report Hash and removes any
208
- # temporary files/resources used in the upload task.
209
- #
210
- # @param files [Hash] a files hash
211
- # @api private
212
- def cleanup(files)
213
- files.select { |_, data| data.key?("zip_io") }.each do |md5, data|
214
- data.fetch("zip_io").unlink
215
- files.fetch(md5).delete("zip_io")
216
- debug { "Cleaned up src_zip #{data["src_zip"]}" }
217
- end
218
- end
219
-
220
- # Creates a remote Base64-encoded temporary file containing a
221
- # PowerShell hash table.
222
- #
223
- # @param hash [String] a String representation of a PowerShell hash
224
- # table
225
- # @return [String] the remote path to the temporary file
226
- # @api private
227
- def create_remote_hash_file(hash)
228
- hash_file = "$env:TEMP\\hash-#{@id_generator.call}.txt"
229
- hash.lines.each { |line| debug { line.chomp } }
230
- StringIO.open(hash) { |io| stream_upload(io, hash_file) }
231
- hash_file
232
- end
233
-
234
- # Runs the decode_files PowerShell script against a collection of
235
- # temporary file/destination path pairs. The PowerShell script returns
236
- # its results as a CSV-formatted report which is converted into a Ruby
237
- # Hash. The script will not be invoked if there are no "dirty" files
238
- # present in the incoming files Hash.
239
- #
240
- # @param files [Hash] files hash, keyed by the local MD5 digest
241
- # @return [Hash] a report hash, keyed by the local MD5 digest
242
- # @api private
243
- def decode_files(files)
244
- decoded_files = decode_files_ps_hash(files)
245
-
246
- if decoded_files == ps_hash(Hash.new)
247
- debug { "No remote files to decode, skipping" }
248
- Hash.new
249
- else
250
- debug { "Running decode_files.ps1" }
251
- hash_file = create_remote_hash_file(decoded_files)
252
- vars = %{$hash_file = "#{hash_file}"\n}
253
-
254
- output = service.run_powershell_script(
255
- ["#{decode_files_script}",
256
- vars,
257
- "Decode-Files (Invoke-Input $hash_file) | ConvertTo-Csv -NoTypeInformation"
258
- ].join("\n")
259
- )
260
- parse_response(output)
261
- end
262
- end
263
-
264
- # Constructs a collection of temporary file/destination path pairs for
265
- # all "dirty" files as a String representation of the contents of a
266
- # PowerShell Hash Table. A "dirty" file is one which has the
267
- # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
268
- #
269
- # @param files [Hash] files hash, keyed by the local MD5 digest
270
- # @return [String] the inner contents of a PowerShell Hash Table
271
- # @api private
272
- def decode_files_ps_hash(files)
273
- result = files.select { |_, data| data["chk_dirty"] == "True" }.map { |_, data|
274
- val = { "dst" => data["dst"] }
275
- val["tmpzip"] = data["tmpzip"] if data["tmpzip"]
276
-
277
- [data["tmpfile"], val]
278
- }
279
-
280
- ps_hash(Hash[result])
281
- end
282
-
283
- # @return [String] the decode_files PowerShell script
284
- # @api private
285
- def decode_files_script
286
- @decode_files_script ||= IO.read(File.join(
287
- File.dirname(__FILE__), %W[.. .. .. support decode_files.ps1]
288
- ))
289
- end
290
-
291
- # Returns a formatted string representing a duration in seconds.
292
- #
293
- # @param total [Integer] the total number of seconds
294
- # @return [String] a formatted string of the form (XmYY.00s)
295
- def duration(total)
296
- total = 0 if total.nil?
297
- minutes = (total / 60).to_i
298
- seconds = (total - (minutes * 60))
299
- format("(%dm%.2fs)", minutes, seconds)
300
- end
301
-
302
- # Contructs a Hash of files or directories, keyed by the local MD5
303
- # digest. Each file entry has a source and destination set, at a
304
- # minimum.
305
- #
306
- # @param locals [Array<String>] a collection of local files or
307
- # directories
308
- # @param remote [String] the base destination path on the remote host
309
- # @return [Hash] files hash, keyed by the local MD5 digest
310
- # @api private
311
- def make_files_hash(locals, remote)
312
- hash = Hash.new
313
- locals.each do |local|
314
- expanded = File.expand_path(local)
315
- expanded += local[-1] if local.end_with?("/", "\\")
316
-
317
- if File.file?(expanded)
318
- add_file_hash!(hash, expanded, remote)
319
- elsif File.directory?(expanded)
320
- add_directory_hash!(hash, expanded, remote)
321
- else
322
- raise Errno::ENOENT, "No such file or directory #{expanded}"
323
- end
324
- end
325
- hash
326
- end
327
-
328
- # @return [String] the MD5 digest of a local file
329
- # @api private
330
- def md5sum(local)
331
- Digest::MD5.file(local).hexdigest
332
- end
333
-
334
- # Destructively merges a report Hash into an existing files Hash.
335
- # **Note:** this method mutates the files Hash.
336
- #
337
- # @param files [Hash] files hash, keyed by the local MD5 digest
338
- # @param report [Hash] report hash, keyed by the local MD5 digest
339
- # @api private
340
- def merge_with_report!(files, report)
341
- files.merge!(report) { |_, oldval, newval| oldval.merge(newval) }
342
- end
343
-
344
- # @param depth [Integer] number of padding characters (default: `0`)
345
- # @return [String] a whitespace padded string of the given length
346
- # @api private
347
- def pad(depth = 0)
348
- " " * depth
349
- end
350
-
351
- # Parses CLIXML String into regular String (without any XML syntax).
352
- # Inspired by https://github.com/WinRb/WinRM/issues/106.
353
- #
354
- # @param clixml [String] clixml text
355
- # @return [String] parsed clixml into String
356
- def clixml_to_s(clixml)
357
- doc = REXML::Document.new(clixml)
358
- text = doc.get_elements("//S").map(&:text).join
359
- text.gsub(/_x(\h\h\h\h)_/) do
360
- code = Regexp.last_match[1]
361
- code.hex.chr
362
- end
363
- end
364
-
365
- # Parses response of a PowerShell script or CMD command which contains
366
- # a CSV-formatted document in the standard output stream.
367
- #
368
- # @param output [WinRM::Output] output object with stdout, stderr, and
369
- # exit code
370
- # @return [Hash] report hash, keyed by the local MD5 digest
371
- # @api private
372
- def parse_response(output)
373
- exitcode = output[:exitcode]
374
- stderr = output.stderr
375
- if stderr.include?("The command line is too long")
376
- # The powershell script which should result in `output` parameter
377
- # is too long, remove some newlines, comments, etc from it.
378
- raise StandardError, "The command line is too long" \
379
- " (powershell script is too long)"
380
- end
381
- pretty_stderr = clixml_to_s(stderr)
382
-
383
- if exitcode != 0
384
- raise FileTransporterFailed, "[#{self.class}] Upload failed " \
385
- "(exitcode: #{exitcode})\n#{pretty_stderr}"
386
- elsif stderr != '\r\n' && stderr != ""
387
- raise FileTransporterFailed, "[#{self.class}] Upload failed " \
388
- "(exitcode: 0), but stderr present\n#{pretty_stderr}"
389
- end
390
-
391
- array = CSV.parse(output.stdout, :headers => true).map(&:to_hash)
392
- array.each { |h| h.each { |key, value| h[key] = nil if value == "" } }
393
- Hash[array.map { |entry| [entry.fetch("src_md5"), entry] }]
394
- end
395
-
396
- # Converts a Ruby hash into a PowerShell hash table, represented in a
397
- # String.
398
- #
399
- # @param obj [Object] source Hash or object when used in recursive
400
- # calls
401
- # @param depth [Integer] padding depth, used in recursive calls
402
- # (default: `0`)
403
- # @return [String] a PowerShell hash table
404
- # @api private
405
- def ps_hash(obj, depth = 0)
406
- if obj.is_a?(Hash)
407
- obj.map { |k, v|
408
- %{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}}
409
- }.join(";\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
410
- else
411
- %{"#{obj}"}
412
- end
413
- end
414
-
415
- # Uploads an IO stream to a Base64-encoded destination file.
416
- #
417
- # **Implementation Note:** Some of the code in this method may appear
418
- # slightly too dense and while adding additional variables would help,
419
- # the code is written very precisely to avoid unwanted allocations
420
- # which will bloat the Ruby VM's object space (and memory footprint).
421
- # The goal here is to stream potentially large files to a remote host
422
- # while not loading the entire file into memory first, then Base64
423
- # encoding it--duplicating the file in memory again.
424
- #
425
- # @param input_io [#read] a readable stream or object to be uploaded
426
- # @param dest [String] path to the destination file on the remote host
427
- # @return [Integer,Integer] the number of resulting upload chunks and
428
- # the number of bytes transferred to the remote host
429
- # @api private
430
- def stream_upload(input_io, dest)
431
- dest_cmd = dest.sub("$env:TEMP", "%TEMP%")
432
- read_size = (MAX_ENCODED_WRITE.to_i / 4) * 3
433
- chunk, bytes = 1, 0
434
- buffer = ""
435
- service.run_cmd(%{echo|set /p=>"#{dest_cmd}"}) # truncate empty file
436
- while input_io.read(read_size, buffer)
437
- bytes += (buffer.bytesize / 3 * 4)
438
- service.run_cmd([buffer].pack(BASE64_PACK).
439
- insert(0, "echo ").concat(%{ >> "#{dest_cmd}"}))
440
- debug { "Wrote chunk #{chunk} for #{dest}" } if chunk % 25 == 0
441
- chunk += 1
442
- end
443
- buffer = nil # rubocop:disable Lint/UselessAssignment
444
-
445
- [chunk - 1, bytes]
446
- end
447
-
448
- # Uploads a local file to a Base64-encoded temporary file.
449
- #
450
- # @param src [String] path to a local file
451
- # @param tmpfile [String] path to the temporary file on the remote
452
- # host
453
- # @return [Integer,Integer] the number of resulting upload chunks and
454
- # the number of bytes transferred to the remote host
455
- # @api private
456
- def stream_upload_file(src, tmpfile)
457
- debug { "Uploading #{src} to encoded tmpfile #{tmpfile}" }
458
- chunks, bytes = 0, 0
459
- elapsed = Benchmark.measure do
460
- File.open(src, "rb") do |io|
461
- chunks, bytes = stream_upload(io, tmpfile)
462
- end
463
- end
464
- debug {
465
- "Finished uploading #{src} to encoded tmpfile #{tmpfile} " \
466
- "(#{bytes.to_f / 1000} KB over #{chunks} chunks) " \
467
- "in #{duration(elapsed.real)}"
468
- }
469
-
470
- [chunks, bytes]
471
- end
472
-
473
- # Uploads a collection of "dirty" files to the remote host as
474
- # Base64-encoded temporary files. A "dirty" file is one which has the
475
- # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
476
- #
477
- # @param files [Hash] files hash, keyed by the local MD5 digest
478
- # @return [Hash] a report hash, keyed by the local MD5 digest
479
- # @api private
480
- def stream_upload_files(files)
481
- response = Hash.new
482
- files.each do |md5, data|
483
- src = data.fetch("src_zip", data["src"])
484
- if data["chk_dirty"] == "True"
485
- tmpfile = "$env:TEMP\\b64-#{md5}.txt"
486
- response[md5] = { "tmpfile" => tmpfile }
487
- chunks, bytes = stream_upload_file(src, tmpfile)
488
- response[md5]["chunks"] = chunks
489
- response[md5]["xfered"] = bytes
490
- else
491
- debug { "File #{data["dst"]} is up to date, skipping" }
492
- end
493
- end
494
- response
495
- end
496
- end
497
- end
498
- end
1
+ # -*- encoding: utf-8 -*-
2
+ #
3
+ # Author:: Fletcher (<fnichol@nichol.ca>)
4
+ #
5
+ # Copyright (C) 2015, Fletcher Nichol
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+
19
+ require "benchmark"
20
+ require "csv"
21
+ require "digest"
22
+ require "securerandom"
23
+ require "stringio"
24
+
25
+ require "winrm/transport/logging"
26
+ require "winrm/transport/tmp_zip"
27
+
28
+ module WinRM
29
+
30
+ module Transport
31
+
32
+ # Wrapped exception for any internally raised WinRM-related errors.
33
+ #
34
+ # @author Fletcher Nichol <fnichol@nichol.ca>
35
+ class FileTransporterFailed < ::WinRM::WinRMError; end
36
+
37
+ # Object which can upload one or more files or directories to a remote
38
+ # host over WinRM using PowerShell scripts and CMD commands. Note that
39
+ # this form of file transfer is *not* ideal and extremely costly on both
40
+ # the local and remote sides. Great pains are made to minimize round
41
+ # trips to the remote host and to minimize the number of PowerShell
42
+ # sessions being invoked which can be 2 orders of magnitude more
43
+ # expensive than vanilla CMD commands.
44
+ #
45
+ # This object is supported by either a `WinRM::WinRMWebService` or
46
+ # `CommandExecutor` instance as it depends on the `#run_cmd` and
47
+ # `#run_powershell_script` API contracts.
48
+ #
49
+ # An optional logger can be supplied, assuming it can respond to the
50
+ # `#debug` and `#debug?` messages.
51
+ #
52
+ # @author Fletcher Nichol <fnichol@nichol.ca>
53
+ # @author Matt Wrock <matt@mattwrock.com>
54
+ class FileTransporter
55
+
56
+ include Logging
57
+
58
+ # Creates a FileTransporter given a service object and optional logger.
59
+ # The service object may be a `WinRM::WinRMWebService` or
60
+ # `CommandExecutor` instance.
61
+ #
62
+ # @param service [WinRM::WinRMWebService,CommandExecutor] a
63
+ # winrm web service object
64
+ # @param logger [#debug,#debug?] an optional logger/ui object that
65
+ # responds to `#debug` and `#debug?` (default: `nil`)
66
+ def initialize(service, logger = nil, opts = {})
67
+ @service = service
68
+ @logger = logger
69
+ @id_generator = opts.fetch(:id_generator) { -> { SecureRandom.uuid } }
70
+ end
71
+
72
+ # Uploads a collection of files and/or directories to the remote host.
73
+ #
74
+ # **TODO Notes:**
75
+ # * options could specify zip mode, zip options, etc.
76
+ # * maybe option to set tmpfile base dir to override $env:PATH?
77
+ # * progress yields block like net-scp progress
78
+ # * final API: def upload(locals, remote, _options = {}, &_progress)
79
+ #
80
+ # @param locals [Array<String>,String] one or more local file or
81
+ # directory paths
82
+ # @param remote [String] the base destination path on the remote host
83
+ # @return [Hash] report hash, keyed by the local MD5 digest
84
+ def upload(locals, remote)
85
+ files = nil
86
+
87
+ elapsed = Benchmark.measure do
88
+ files = make_files_hash(Array(locals), remote)
89
+
90
+ report = check_files(files)
91
+ merge_with_report!(files, report)
92
+
93
+ report = stream_upload_files(files)
94
+ merge_with_report!(files, report)
95
+
96
+ report = decode_files(files)
97
+ merge_with_report!(files, report)
98
+
99
+ cleanup(files)
100
+ end
101
+
102
+ debug {
103
+ "Uploaded #{files.keys.size} items " \
104
+ "in #{duration(elapsed.real)}"
105
+ }
106
+
107
+ files
108
+ end
109
+
110
+ private
111
+
112
+ # @return [Integer] the maximum number of bytes that can be supplied on
113
+ # a Windows CMD prompt without exceeded the maximum command line
114
+ # length
115
+ # @api private
116
+ MAX_ENCODED_WRITE = 8000
117
+
118
+ # @return [String] the Array pack template for Base64 encoding a stream
119
+ # of data
120
+ # @api private
121
+ BASE64_PACK = "m0".freeze
122
+
123
+ # @return [#debug,#debug?] the logger
124
+ # @api private
125
+ attr_reader :logger
126
+
127
+ # @return [WinRM::WinRMWebService,Winrm::CommandExecutor] a WinRM web
128
+ # service object
129
+ # @api private
130
+ attr_reader :service
131
+
132
+ # Adds an entry to a files Hash (keyed by local MD5 digest) for a
133
+ # directory. When a directory is added, a temporary Zip file is created
134
+ # containing the contents of the directory and any file-related data
135
+ # such as MD5 digest, size, etc. will be referring to the Zip file.
136
+ #
137
+ # @param hash [Hash] hash to be mutated
138
+ # @param dir [String] directory path to be Zipped and added
139
+ # @param remote [String] path to destination on remote host
140
+ # @api private
141
+ def add_directory_hash!(hash, dir, remote)
142
+ zip_io = TmpZip.new(dir, logger)
143
+ zip_md5 = md5sum(zip_io.path)
144
+
145
+ hash[zip_md5] = {
146
+ "src" => dir,
147
+ "src_zip" => zip_io.path.to_s,
148
+ "zip_io" => zip_io,
149
+ "tmpzip" => "$env:TEMP\\tmpzip-#{zip_md5}.zip",
150
+ "dst" => remote,
151
+ "size" => File.size(zip_io.path)
152
+ }
153
+ end
154
+
155
+ # Adds an entry to a files Hash (keyed by local MD5 digest) for a file.
156
+ #
157
+ # @param hash [Hash] hash to be mutated
158
+ # @param local [String] file path
159
+ # @param remote [String] path to destination on remote host
160
+ # @api private
161
+ def add_file_hash!(hash, local, remote)
162
+ hash[md5sum(local)] = {
163
+ "src" => local,
164
+ "dst" => "#{remote}\\#{File.basename(local)}",
165
+ "size" => File.size(local)
166
+ }
167
+ end
168
+
169
+ # Runs the check_files PowerShell script against a collection of
170
+ # destination path/MD5 checksum pairs. The PowerShell script returns
171
+ # its results as a CSV-formatted report which is converted into a Ruby
172
+ # Hash.
173
+ #
174
+ # @param files [Hash] files hash, keyed by the local MD5 digest
175
+ # @return [Hash] a report hash, keyed by the local MD5 digest
176
+ # @api private
177
+ def check_files(files)
178
+ debug { "Running check_files.ps1" }
179
+ hash_file = create_remote_hash_file(check_files_ps_hash(files))
180
+ vars = %{$hash_file = "#{hash_file}"\n}
181
+ output = service.run_powershell_script(
182
+ [vars, check_files_script].join("\n")
183
+ )
184
+ parse_response(output)
185
+ end
186
+
187
+ # Constructs a collection of destination path/MD5 checksum pairs as a
188
+ # String representation of the contents of a PowerShell Hash Table.
189
+ #
190
+ # @param files [Hash] files hash, keyed by the local MD5 digest
191
+ # @return [String] the inner contents of a PowerShell Hash Table
192
+ # @api private
193
+ def check_files_ps_hash(files)
194
+ ps_hash(Hash[
195
+ files.map { |md5, data| [data.fetch("tmpzip", data["dst"]), md5] }
196
+ ])
197
+ end
198
+
199
+ # @return [String] the check_files PowerShell script
200
+ # @api private
201
+ def check_files_script
202
+ @check_files_script ||= IO.read(File.join(
203
+ File.dirname(__FILE__), %W[.. .. .. support check_files.ps1]
204
+ ))
205
+ end
206
+
207
+ # Performs any final cleanup on the report Hash and removes any
208
+ # temporary files/resources used in the upload task.
209
+ #
210
+ # @param files [Hash] a files hash
211
+ # @api private
212
+ def cleanup(files)
213
+ files.select { |_, data| data.key?("zip_io") }.each do |md5, data|
214
+ data.fetch("zip_io").unlink
215
+ files.fetch(md5).delete("zip_io")
216
+ debug { "Cleaned up src_zip #{data["src_zip"]}" }
217
+ end
218
+ end
219
+
220
+ # Creates a remote Base64-encoded temporary file containing a
221
+ # PowerShell hash table.
222
+ #
223
+ # @param hash [String] a String representation of a PowerShell hash
224
+ # table
225
+ # @return [String] the remote path to the temporary file
226
+ # @api private
227
+ def create_remote_hash_file(hash)
228
+ hash_file = "$env:TEMP\\hash-#{@id_generator.call}.txt"
229
+ hash.lines.each { |line| debug { line.chomp } }
230
+ StringIO.open(hash) { |io| stream_upload(io, hash_file) }
231
+ hash_file
232
+ end
233
+
234
+ # Runs the decode_files PowerShell script against a collection of
235
+ # temporary file/destination path pairs. The PowerShell script returns
236
+ # its results as a CSV-formatted report which is converted into a Ruby
237
+ # Hash. The script will not be invoked if there are no "dirty" files
238
+ # present in the incoming files Hash.
239
+ #
240
+ # @param files [Hash] files hash, keyed by the local MD5 digest
241
+ # @return [Hash] a report hash, keyed by the local MD5 digest
242
+ # @api private
243
+ def decode_files(files)
244
+ decoded_files = decode_files_ps_hash(files)
245
+
246
+ if decoded_files == ps_hash(Hash.new)
247
+ debug { "No remote files to decode, skipping" }
248
+ Hash.new
249
+ else
250
+ debug { "Running decode_files.ps1" }
251
+ hash_file = create_remote_hash_file(decoded_files)
252
+ vars = %{$hash_file = "#{hash_file}"\n}
253
+
254
+ output = service.run_powershell_script(
255
+ ["#{decode_files_script}",
256
+ vars,
257
+ "Decode-Files (Invoke-Input $hash_file) | ConvertTo-Csv -NoTypeInformation"
258
+ ].join("\n")
259
+ )
260
+ parse_response(output)
261
+ end
262
+ end
263
+
264
+ # Constructs a collection of temporary file/destination path pairs for
265
+ # all "dirty" files as a String representation of the contents of a
266
+ # PowerShell Hash Table. A "dirty" file is one which has the
267
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
268
+ #
269
+ # @param files [Hash] files hash, keyed by the local MD5 digest
270
+ # @return [String] the inner contents of a PowerShell Hash Table
271
+ # @api private
272
+ def decode_files_ps_hash(files)
273
+ result = files.select { |_, data| data["chk_dirty"] == "True" }.map { |_, data|
274
+ val = { "dst" => data["dst"] }
275
+ val["tmpzip"] = data["tmpzip"] if data["tmpzip"]
276
+
277
+ [data["tmpfile"], val]
278
+ }
279
+
280
+ ps_hash(Hash[result])
281
+ end
282
+
283
+ # @return [String] the decode_files PowerShell script
284
+ # @api private
285
+ def decode_files_script
286
+ @decode_files_script ||= IO.read(File.join(
287
+ File.dirname(__FILE__), %W[.. .. .. support decode_files.ps1]
288
+ ))
289
+ end
290
+
291
+ # Returns a formatted string representing a duration in seconds.
292
+ #
293
+ # @param total [Integer] the total number of seconds
294
+ # @return [String] a formatted string of the form (XmYY.00s)
295
+ def duration(total)
296
+ total = 0 if total.nil?
297
+ minutes = (total / 60).to_i
298
+ seconds = (total - (minutes * 60))
299
+ format("(%dm%.2fs)", minutes, seconds)
300
+ end
301
+
302
+ # Contructs a Hash of files or directories, keyed by the local MD5
303
+ # digest. Each file entry has a source and destination set, at a
304
+ # minimum.
305
+ #
306
+ # @param locals [Array<String>] a collection of local files or
307
+ # directories
308
+ # @param remote [String] the base destination path on the remote host
309
+ # @return [Hash] files hash, keyed by the local MD5 digest
310
+ # @api private
311
+ def make_files_hash(locals, remote)
312
+ hash = Hash.new
313
+ locals.each do |local|
314
+ expanded = File.expand_path(local)
315
+ expanded += local[-1] if local.end_with?("/", "\\")
316
+
317
+ if File.file?(expanded)
318
+ add_file_hash!(hash, expanded, remote)
319
+ elsif File.directory?(expanded)
320
+ add_directory_hash!(hash, expanded, remote)
321
+ else
322
+ raise Errno::ENOENT, "No such file or directory #{expanded}"
323
+ end
324
+ end
325
+ hash
326
+ end
327
+
328
+ # @return [String] the MD5 digest of a local file
329
+ # @api private
330
+ def md5sum(local)
331
+ Digest::MD5.file(local).hexdigest
332
+ end
333
+
334
+ # Destructively merges a report Hash into an existing files Hash.
335
+ # **Note:** this method mutates the files Hash.
336
+ #
337
+ # @param files [Hash] files hash, keyed by the local MD5 digest
338
+ # @param report [Hash] report hash, keyed by the local MD5 digest
339
+ # @api private
340
+ def merge_with_report!(files, report)
341
+ files.merge!(report) { |_, oldval, newval| oldval.merge(newval) }
342
+ end
343
+
344
+ # @param depth [Integer] number of padding characters (default: `0`)
345
+ # @return [String] a whitespace padded string of the given length
346
+ # @api private
347
+ def pad(depth = 0)
348
+ " " * depth
349
+ end
350
+
351
+ # Parses CLIXML String into regular String (without any XML syntax).
352
+ # Inspired by https://github.com/WinRb/WinRM/issues/106.
353
+ #
354
+ # @param clixml [String] clixml text
355
+ # @return [String] parsed clixml into String
356
+ def clixml_to_s(clixml)
357
+ doc = REXML::Document.new(clixml)
358
+ text = doc.get_elements("//S").map(&:text).join
359
+ text.gsub(/_x(\h\h\h\h)_/) do
360
+ code = Regexp.last_match[1]
361
+ code.hex.chr
362
+ end
363
+ end
364
+
365
+ # Parses response of a PowerShell script or CMD command which contains
366
+ # a CSV-formatted document in the standard output stream.
367
+ #
368
+ # @param output [WinRM::Output] output object with stdout, stderr, and
369
+ # exit code
370
+ # @return [Hash] report hash, keyed by the local MD5 digest
371
+ # @api private
372
+ def parse_response(output)
373
+ exitcode = output[:exitcode]
374
+ stderr = output.stderr
375
+ if stderr.include?("The command line is too long")
376
+ # The powershell script which should result in `output` parameter
377
+ # is too long, remove some newlines, comments, etc from it.
378
+ raise StandardError, "The command line is too long" \
379
+ " (powershell script is too long)"
380
+ end
381
+ pretty_stderr = clixml_to_s(stderr)
382
+
383
+ if exitcode != 0
384
+ raise FileTransporterFailed, "[#{self.class}] Upload failed " \
385
+ "(exitcode: #{exitcode})\n#{pretty_stderr}"
386
+ elsif stderr != '\r\n' && stderr != ""
387
+ raise FileTransporterFailed, "[#{self.class}] Upload failed " \
388
+ "(exitcode: 0), but stderr present\n#{pretty_stderr}"
389
+ end
390
+
391
+ array = CSV.parse(output.stdout, :headers => true).map(&:to_hash)
392
+ array.each { |h| h.each { |key, value| h[key] = nil if value == "" } }
393
+ Hash[array.map { |entry| [entry.fetch("src_md5"), entry] }]
394
+ end
395
+
396
+ # Converts a Ruby hash into a PowerShell hash table, represented in a
397
+ # String.
398
+ #
399
+ # @param obj [Object] source Hash or object when used in recursive
400
+ # calls
401
+ # @param depth [Integer] padding depth, used in recursive calls
402
+ # (default: `0`)
403
+ # @return [String] a PowerShell hash table
404
+ # @api private
405
+ def ps_hash(obj, depth = 0)
406
+ if obj.is_a?(Hash)
407
+ obj.map { |k, v|
408
+ %{#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)}}
409
+ }.join(";\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
410
+ else
411
+ %{"#{obj}"}
412
+ end
413
+ end
414
+
415
+ # Uploads an IO stream to a Base64-encoded destination file.
416
+ #
417
+ # **Implementation Note:** Some of the code in this method may appear
418
+ # slightly too dense and while adding additional variables would help,
419
+ # the code is written very precisely to avoid unwanted allocations
420
+ # which will bloat the Ruby VM's object space (and memory footprint).
421
+ # The goal here is to stream potentially large files to a remote host
422
+ # while not loading the entire file into memory first, then Base64
423
+ # encoding it--duplicating the file in memory again.
424
+ #
425
+ # @param input_io [#read] a readable stream or object to be uploaded
426
+ # @param dest [String] path to the destination file on the remote host
427
+ # @return [Integer,Integer] the number of resulting upload chunks and
428
+ # the number of bytes transferred to the remote host
429
+ # @api private
430
+ def stream_upload(input_io, dest)
431
+ dest_cmd = dest.sub("$env:TEMP", "%TEMP%")
432
+ read_size = (MAX_ENCODED_WRITE.to_i / 4) * 3
433
+ chunk, bytes = 1, 0
434
+ buffer = ""
435
+ service.run_cmd(%{echo|set /p=>"#{dest_cmd}"}) # truncate empty file
436
+ while input_io.read(read_size, buffer)
437
+ bytes += (buffer.bytesize / 3 * 4)
438
+ service.run_cmd([buffer].pack(BASE64_PACK).
439
+ insert(0, "echo ").concat(%{ >> "#{dest_cmd}"}))
440
+ debug { "Wrote chunk #{chunk} for #{dest}" } if chunk % 25 == 0
441
+ chunk += 1
442
+ end
443
+ buffer = nil # rubocop:disable Lint/UselessAssignment
444
+
445
+ [chunk - 1, bytes]
446
+ end
447
+
448
+ # Uploads a local file to a Base64-encoded temporary file.
449
+ #
450
+ # @param src [String] path to a local file
451
+ # @param tmpfile [String] path to the temporary file on the remote
452
+ # host
453
+ # @return [Integer,Integer] the number of resulting upload chunks and
454
+ # the number of bytes transferred to the remote host
455
+ # @api private
456
+ def stream_upload_file(src, tmpfile)
457
+ debug { "Uploading #{src} to encoded tmpfile #{tmpfile}" }
458
+ chunks, bytes = 0, 0
459
+ elapsed = Benchmark.measure do
460
+ File.open(src, "rb") do |io|
461
+ chunks, bytes = stream_upload(io, tmpfile)
462
+ end
463
+ end
464
+ debug {
465
+ "Finished uploading #{src} to encoded tmpfile #{tmpfile} " \
466
+ "(#{bytes.to_f / 1000} KB over #{chunks} chunks) " \
467
+ "in #{duration(elapsed.real)}"
468
+ }
469
+
470
+ [chunks, bytes]
471
+ end
472
+
473
+ # Uploads a collection of "dirty" files to the remote host as
474
+ # Base64-encoded temporary files. A "dirty" file is one which has the
475
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
476
+ #
477
+ # @param files [Hash] files hash, keyed by the local MD5 digest
478
+ # @return [Hash] a report hash, keyed by the local MD5 digest
479
+ # @api private
480
+ def stream_upload_files(files)
481
+ response = Hash.new
482
+ files.each do |md5, data|
483
+ src = data.fetch("src_zip", data["src"])
484
+ if data["chk_dirty"] == "True"
485
+ tmpfile = "$env:TEMP\\b64-#{md5}.txt"
486
+ response[md5] = { "tmpfile" => tmpfile }
487
+ chunks, bytes = stream_upload_file(src, tmpfile)
488
+ response[md5]["chunks"] = chunks
489
+ response[md5]["xfered"] = bytes
490
+ else
491
+ debug { "File #{data["dst"]} is up to date, skipping" }
492
+ end
493
+ end
494
+ response
495
+ end
496
+ end
497
+ end
498
+ end