winrm-transport 1.0.2 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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