winrm-fs 0.3.1 → 0.3.2

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