winrm-fs 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/changelog.md CHANGED
@@ -1,34 +1,37 @@
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
1
+ # WinRM-fs Gem Changelog
2
+
3
+ # 0.4.0
4
+ - Correct the destination path of individual files. Always assume it is the full destination path unless it is an existing directory. This may potentialy break some callers expecting the remote path to be a directory that winrm-fs will create if missing as the destination of the local file. A new directory will not be created and the local file will be uploaded directly to the remote path.
5
+
6
+ # 0.3.2
7
+ - Fix re-extraction of cached directories from temp folder when there is more than one "clean" directory deleted from destination
8
+
9
+ # 0.3.1
10
+ - Widen logging version constraints to include 2.0 (matching WinRM core gem)
11
+
12
+ # 0.3.0
13
+ - 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:
14
+ - 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.
15
+ - `Upload` may now receive an array of source files and directories rather than just a single file or directory path.
16
+
17
+ # 0.2.4
18
+ - Fix issue 21, downloading files is extremely slow.
19
+ - Add zip file creation debug logging.
20
+
21
+ # 0.2.3
22
+ - Fix yielding progress data, issue #23
23
+
24
+ # 0.2.2
25
+ - Fix powershell streams leaking to standard error breaking Windows 10, issue #18
26
+
27
+ # 0.2.1
28
+ - Fixed issue 16 creating zip file on Windows
29
+
30
+ # 0.2.0
31
+ - Redesigned temp zip file creation system
32
+ - Fixed lots of small edge case issues especially with directory uploads
33
+ - Simplified file manager upload method API to take only a single source file or directory
34
+ - Expanded acceptable username and hostnames for rwinrmcp
35
+
36
+ # 0.1.0
37
+ - 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,507 +1,509 @@
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
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
+ hash[md5sum(local)] = {
168
+ 'src' => local,
169
+ 'dst' => remote,
170
+ 'size' => File.size(local)
171
+ }
172
+ end
173
+
174
+ # Runs the check_files PowerShell script against a collection of
175
+ # destination path/MD5 checksum pairs. The PowerShell script returns
176
+ # its results as a CSV-formatted report which is converted into a Ruby
177
+ # Hash.
178
+ #
179
+ # @param files [Hash] files hash, keyed by the local MD5 digest
180
+ # @return [Hash] a report hash, keyed by the local MD5 digest
181
+ # @api private
182
+ def check_files(files)
183
+ logger.debug 'Running check_files.ps1'
184
+ hash_file = create_remote_hash_file(check_files_ps_hash(files))
185
+ script = WinRM::FS::Scripts.render('check_files', hash_file: hash_file)
186
+ parse_response(executor.run_powershell_script(script))
187
+ end
188
+
189
+ # Constructs a collection of destination path/MD5 checksum pairs as a
190
+ # String representation of the contents of a PowerShell Hash Table.
191
+ #
192
+ # @param files [Hash] files hash, keyed by the local MD5 digest
193
+ # @return [String] the inner contents of a PowerShell Hash Table
194
+ # @api private
195
+ def check_files_ps_hash(files)
196
+ hash = files.map do |md5, data|
197
+ [
198
+ md5,
199
+ {
200
+ 'target' => data.fetch('tmpzip', data['dst']),
201
+ 'src_basename' => File.basename(data['src']),
202
+ 'dst' => data['dst']
203
+ }
204
+ ]
205
+ end
206
+ ps_hash(Hash[hash])
207
+ end
208
+
209
+ # Performs any final cleanup on the report Hash and removes any
210
+ # temporary files/resources used in the upload task.
211
+ #
212
+ # @param files [Hash] a files hash
213
+ # @api private
214
+ def cleanup(files)
215
+ files.select { |_, data| data.key?('zip_io') }.each do |md5, data|
216
+ data.fetch('zip_io').unlink
217
+ files.fetch(md5).delete('zip_io')
218
+ logger.debug "Cleaned up src_zip #{data['src_zip']}"
219
+ end
220
+ end
221
+
222
+ # Creates a remote Base64-encoded temporary file containing a
223
+ # PowerShell hash table.
224
+ #
225
+ # @param hash [String] a String representation of a PowerShell hash
226
+ # table
227
+ # @return [String] the remote path to the temporary file
228
+ # @api private
229
+ def create_remote_hash_file(hash)
230
+ hash_file = "$env:TEMP\\hash-#{@id_generator.call}.txt"
231
+ hash.lines.each { |line| logger.debug line.chomp }
232
+ StringIO.open(hash) { |io| stream_upload(io, hash_file) }
233
+ hash_file
234
+ end
235
+
236
+ # Runs the decode_files PowerShell script against a collection of
237
+ # temporary file/destination path pairs. The PowerShell script returns
238
+ # its results as a CSV-formatted report which is converted into a Ruby
239
+ # Hash. The script will not be invoked if there are no "dirty" files
240
+ # present in the incoming files Hash.
241
+ #
242
+ # @param files [Hash] files hash, keyed by the local MD5 digest
243
+ # @return [Hash] a report hash, keyed by the local MD5 digest
244
+ # @api private
245
+ def decode_files(files)
246
+ decoded_files = decode_files_ps_hash(files)
247
+
248
+ if decoded_files == ps_hash({})
249
+ logger.debug 'No remote files to decode, skipping'
250
+ {}
251
+ else
252
+ logger.debug 'Running decode_files.ps1'
253
+ hash_file = create_remote_hash_file(decoded_files)
254
+ script = WinRM::FS::Scripts.render('decode_files', hash_file: hash_file)
255
+
256
+ parse_response(executor.run_powershell_script(script))
257
+ end
258
+ end
259
+
260
+ # Constructs a collection of temporary file/destination path pairs for
261
+ # all "dirty" files as a String representation of the contents of a
262
+ # PowerShell Hash Table. A "dirty" file is one which has the
263
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
264
+ #
265
+ # @param files [Hash] files hash, keyed by the local MD5 digest
266
+ # @return [String] the inner contents of a PowerShell Hash Table
267
+ # @api private
268
+ def decode_files_ps_hash(files)
269
+ file_data = files.select do |_, data|
270
+ data['chk_dirty'] == 'True' || data.key?('tmpzip')
271
+ end
272
+
273
+ i = 0
274
+ result = file_data.map do |_, data|
275
+ val = { 'dst' => data['dst'] }
276
+ val['tmpzip'] = data['tmpzip'] if data['tmpzip']
277
+
278
+ [data['tmpfile'] || "clean#{i += 1}", val]
279
+ end
280
+
281
+ ps_hash(Hash[result])
282
+ end
283
+
284
+ # Returns a formatted string representing a duration in seconds.
285
+ #
286
+ # @param total [Integer] the total number of seconds
287
+ # @return [String] a formatted string of the form (XmYY.00s)
288
+ def duration(total)
289
+ total = 0 if total.nil?
290
+ minutes = (total / 60).to_i
291
+ seconds = (total - (minutes * 60))
292
+ format('(%dm%.2fs)', minutes, seconds)
293
+ end
294
+
295
+ # Contructs a Hash of files or directories, keyed by the local MD5
296
+ # digest. Each file entry has a source and destination set, at a
297
+ # minimum.
298
+ #
299
+ # @param locals [Array<String>] a collection of local files or
300
+ # directories
301
+ # @param remote [String] the base destination path on the remote host
302
+ # @return [Hash] files hash, keyed by the local MD5 digest
303
+ # @api private
304
+ def make_files_hash(locals, remote)
305
+ hash = {}
306
+ locals.each do |local|
307
+ expanded = File.expand_path(local)
308
+ expanded += local[-1] if local.end_with?('/', '\\')
309
+
310
+ if File.file?(expanded)
311
+ add_file_hash!(hash, expanded, remote)
312
+ elsif File.directory?(expanded)
313
+ add_directory_hash!(hash, expanded, remote)
314
+ else
315
+ fail Errno::ENOENT, "No such file or directory #{expanded}"
316
+ end
317
+ end
318
+ hash
319
+ end
320
+
321
+ # @return [String] the MD5 digest of a local file
322
+ # @api private
323
+ def md5sum(local)
324
+ Digest::MD5.file(local).hexdigest
325
+ end
326
+
327
+ # Destructively merges a report Hash into an existing files Hash.
328
+ # **Note:** this method mutates the files Hash.
329
+ #
330
+ # @param files [Hash] files hash, keyed by the local MD5 digest
331
+ # @param report [Hash] report hash, keyed by the local MD5 digest
332
+ # @api private
333
+ def merge_with_report!(files, report)
334
+ files.merge!(report) { |_, oldval, newval| oldval.merge(newval) }
335
+ end
336
+
337
+ # @param depth [Integer] number of padding characters (default: `0`)
338
+ # @return [String] a whitespace padded string of the given length
339
+ # @api private
340
+ def pad(depth = 0)
341
+ ' ' * depth
342
+ end
343
+
344
+ # Parses CLIXML String into regular String (without any XML syntax).
345
+ # Inspired by https://github.com/WinRb/WinRM/issues/106.
346
+ #
347
+ # @param clixml [String] clixml text
348
+ # @return [String] parsed clixml into String
349
+ def clixml_to_s(clixml)
350
+ doc = REXML::Document.new(clixml)
351
+ text = doc.get_elements('//S').map(&:text).join
352
+ text.gsub(/_x(\h\h\h\h)_/) do
353
+ code = Regexp.last_match[1]
354
+ code.hex.chr
355
+ end
356
+ end
357
+
358
+ # Parses response of a PowerShell script or CMD command which contains
359
+ # a CSV-formatted document in the standard output stream.
360
+ #
361
+ # @param output [WinRM::Output] output object with stdout, stderr, and
362
+ # exit code
363
+ # @return [Hash] report hash, keyed by the local MD5 digest
364
+ # @api private
365
+ def parse_response(output)
366
+ exitcode = output[:exitcode]
367
+ stderr = output.stderr
368
+ if stderr.include?('The command line is too long')
369
+ # The powershell script which should result in `output` parameter
370
+ # is too long, remove some newlines, comments, etc from it.
371
+ fail StandardError, 'The command line is too long' \
372
+ ' (powershell script is too long)'
373
+ end
374
+ pretty_stderr = clixml_to_s(stderr)
375
+
376
+ if exitcode != 0
377
+ fail FileTransporterFailed, "[#{self.class}] Upload failed " \
378
+ "(exitcode: #{exitcode})\n#{pretty_stderr}"
379
+ elsif stderr != '\r\n' && stderr != ''
380
+ fail FileTransporterFailed, "[#{self.class}] Upload failed " \
381
+ "(exitcode: 0), but stderr present\n#{pretty_stderr}"
382
+ end
383
+
384
+ array = CSV.parse(output.stdout, headers: true).map(&:to_hash)
385
+ array.each { |h| h.each { |key, value| h[key] = nil if value == '' } }
386
+ Hash[array.map { |entry| [entry.fetch('src_md5'), entry] }]
387
+ end
388
+
389
+ # Converts a Ruby hash into a PowerShell hash table, represented in a
390
+ # String.
391
+ #
392
+ # @param obj [Object] source Hash or object when used in recursive
393
+ # calls
394
+ # @param depth [Integer] padding depth, used in recursive calls
395
+ # (default: `0`)
396
+ # @return [String] a PowerShell hash table
397
+ # @api private
398
+ def ps_hash(obj, depth = 0)
399
+ if obj.is_a?(Hash)
400
+ obj.map do |k, v|
401
+ %(#{pad(depth + 2)}#{ps_hash(k)} = #{ps_hash(v, depth + 2)})
402
+ end.join(";\n").insert(0, "@{\n").insert(-1, "\n#{pad(depth)}}")
403
+ else
404
+ %("#{obj}")
405
+ end
406
+ end
407
+
408
+ # Uploads an IO stream to a Base64-encoded destination file.
409
+ #
410
+ # **Implementation Note:** Some of the code in this method may appear
411
+ # slightly too dense and while adding additional variables would help,
412
+ # the code is written very precisely to avoid unwanted allocations
413
+ # which will bloat the Ruby VM's object space (and memory footprint).
414
+ # The goal here is to stream potentially large files to a remote host
415
+ # while not loading the entire file into memory first, then Base64
416
+ # encoding it--duplicating the file in memory again.
417
+ #
418
+ # @param input_io [#read] a readable stream or object to be uploaded
419
+ # @param dest [String] path to the destination file on the remote host
420
+ # @return [Integer,Integer] the number of resulting upload chunks and
421
+ # the number of bytes transferred to the remote host
422
+ # @api private
423
+ def stream_upload(input_io, dest)
424
+ dest_cmd = dest.sub('$env:TEMP', '%TEMP%')
425
+ read_size = (MAX_ENCODED_WRITE.to_i / 4) * 3
426
+ chunk, bytes = 1, 0
427
+ buffer = ''
428
+ executor.run_cmd(%(echo|set /p=>"#{dest_cmd}")) # truncate empty file
429
+ while input_io.read(read_size, buffer)
430
+ bytes += (buffer.bytesize / 3 * 4)
431
+ executor.run_cmd([buffer].pack(BASE64_PACK)
432
+ .insert(0, 'echo ').concat(%( >> "#{dest_cmd}")))
433
+ logger.debug "Wrote chunk #{chunk} for #{dest}" if chunk % 25 == 0
434
+ chunk += 1
435
+ yield bytes if block_given?
436
+ end
437
+ buffer = nil # rubocop:disable Lint/UselessAssignment
438
+
439
+ [chunk - 1, bytes]
440
+ end
441
+
442
+ # Uploads a local file to a Base64-encoded temporary file.
443
+ #
444
+ # @param src [String] path to a local file
445
+ # @param tmpfile [String] path to the temporary file on the remote
446
+ # host
447
+ # @return [Integer,Integer] the number of resulting upload chunks and
448
+ # the number of bytes transferred to the remote host
449
+ # @api private
450
+ def stream_upload_file(src, tmpfile, &block)
451
+ logger.debug "Uploading #{src} to encoded tmpfile #{tmpfile}"
452
+ chunks, bytes = 0, 0
453
+ elapsed = Benchmark.measure do
454
+ File.open(src, 'rb') do |io|
455
+ chunks, bytes = stream_upload(io, tmpfile, &block)
456
+ end
457
+ end
458
+ logger.debug(
459
+ "Finished uploading #{src} to encoded tmpfile #{tmpfile} " \
460
+ "(#{bytes.to_f / 1000} KB over #{chunks} chunks) " \
461
+ "in #{duration(elapsed.real)}"
462
+ )
463
+
464
+ [chunks, bytes]
465
+ end
466
+
467
+ # Uploads a collection of "dirty" files to the remote host as
468
+ # Base64-encoded temporary files. A "dirty" file is one which has the
469
+ # `"chk_dirty"` option set to `"True"` in the incoming files Hash.
470
+ #
471
+ # @param files [Hash] files hash, keyed by the local MD5 digest
472
+ # @return [Hash] a report hash, keyed by the local MD5 digest
473
+ # @api private
474
+ def stream_upload_files(files)
475
+ response = {}
476
+ files.each do |md5, data|
477
+ src = data.fetch('src_zip', data['src'])
478
+ if data['chk_dirty'] == 'True'
479
+ tmpfile = "$env:TEMP\\b64-#{md5}.txt"
480
+ response[md5] = { 'tmpfile' => tmpfile }
481
+ chunks, bytes = stream_upload_file(src, tmpfile) do |xfered|
482
+ yield data['src'], xfered
483
+ end
484
+ response[md5]['chunks'] = chunks
485
+ response[md5]['xfered'] = bytes
486
+ else
487
+ logger.debug "File #{data['dst']} is up to date, skipping"
488
+ end
489
+ end
490
+ response
491
+ end
492
+
493
+ # Total by byte count to be transferred.
494
+ # Calculates count based on the sum of base64 encoded content size
495
+ # of all files base 64 that are dirty.
496
+ #
497
+ # @param files [Hash] files hash, keyed by the local MD5 digest
498
+ # @return [Fixnum] total byte size
499
+ # @api private
500
+ def total_base64_transfer_size(files)
501
+ size = 0
502
+ files.values.each { |file| size += file['size'] if file['chk_dirty'] == 'True' }
503
+ size / 3 * 4
504
+ end
505
+ end
506
+ # rubocop:enable MethodLength, AbcSize, ClassLength
507
+ end
508
+ end
509
+ end