winrm-fs 0.2.3 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: b1a70aed7f4cd1c07905037789c4063351a4f5f0
4
- data.tar.gz: 33d94b11b030a71ceaddb83d4c91527ad6ee1b5f
3
+ metadata.gz: 43664865f26197083cb673678b4c3bb025bab2d1
4
+ data.tar.gz: dd8d7bef7354eac8b17f68d640b9c17fc62c6486
5
5
  SHA512:
6
- metadata.gz: d03094f7a70dffd988e3c494b3041d1212b31c24ea2e9e36d6ee746914f144935c9cf4af202943ef3db8372275e65fe7fcf18034ca2db4db8a6c72fa017c5e63
7
- data.tar.gz: aee15954eddd17e38e30b0c45d7cf2769a14a4c81c41fbe60761b0720f91cbc3d1ee385e99693bc578803422968245dfb0c655ee93fce73ef987bfbe4b5a8667
6
+ metadata.gz: ce71234909e6381bda099cda4b7bbdae98bde75464348c82d6dca8458ba09fa9620f70ae11451da6c225d9d8b670c1956fef5f78c2fb034f9872a03574e4a70d
7
+ data.tar.gz: e0a138ea8d3f2b77711e4097902eadf7c2ed591cb870d73a33b7f7fcbc17b83d5575c3e5381dcb2bae9a4d071f0b641278d7dce36ab51f838c4ff53951c2452f
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # File system operations over Windows Remote Management (WinRM) for Ruby
2
2
  [![Build Status](https://travis-ci.org/WinRb/winrm-fs.svg?branch=master)](https://travis-ci.org/WinRb/winrm-fs)
3
3
  [![Gem Version](https://badge.fury.io/rb/winrm-fs.svg)](http://badge.fury.io/rb/winrm-fs)
4
+ [![Build status](https://ci.appveyor.com/api/projects/status/wm6apa8ojfhfmwsf?svg=true)](https://ci.appveyor.com/project/winrb/winrm-fs)
4
5
 
5
6
  ## Uploading files
6
- Files may be copied from the local machine to the winrm endpoint. Individual
7
- files or directories may be specified:
7
+ Files may be copied from the local machine to the winrm endpoint. Individual files or directories, as well as arrays of files and directories may be specified:
8
8
  ```ruby
9
9
  require 'winrm-fs'
10
10
 
@@ -14,11 +14,15 @@ file_manager = WinRM::FS::FileManager.new(service)
14
14
  # upload file.txt from the current working directory
15
15
  file_manager.upload('file.txt', 'c:/file.txt')
16
16
 
17
- # upload the entire contents of my_dir to c:/foo/my_dir
17
+ # upload the my_dir directory to c:/foo/my_dir
18
18
  file_manager.upload('/Users/sneal/my_dir', 'c:/foo/my_dir')
19
19
 
20
- # upload the entire directory contents of foo to c:\program files\bar
21
- file_manager.upload('/Users/sneal/foo', '$env:ProgramFiles/bar')
20
+ # upload multiple directories and a file to c:\programData
21
+ file_manager.upload([
22
+ '/Users/sneal/foo1',
23
+ '/Users/sneal/foo2'
24
+ '/Users/sneal/fluffy.txt'
25
+ ], '$env:ProgramData')
22
26
  ```
23
27
 
24
28
  ### Handling progress events
data/Rakefile CHANGED
@@ -11,16 +11,14 @@ Dir.chdir(File.expand_path('../', __FILE__))
11
11
  require 'bundler/gem_tasks'
12
12
 
13
13
  RSpec::Core::RakeTask.new(:spec) do |task|
14
- task.pattern = 'test/spec/*_spec.rb'
14
+ task.pattern = 'spec/unit/*_spec.rb'
15
15
  task.rspec_opts = ['--color', '-f documentation']
16
- task.rspec_opts << '-tunit'
17
16
  end
18
17
 
19
18
  # Run the integration test suite
20
19
  RSpec::Core::RakeTask.new(:integration) do |task|
21
- task.pattern = 'test/spec/*_spec.rb'
20
+ task.pattern = 'spec/integration/*_spec.rb'
22
21
  task.rspec_opts = ['--color', '-f documentation']
23
- task.rspec_opts << '-tintegration'
24
22
  end
25
23
 
26
24
  RuboCop::RakeTask.new
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.3
1
+ 0.3.0
data/appveyor.yml ADDED
@@ -0,0 +1,39 @@
1
+ version: "master-{build}"
2
+
3
+ os: Windows Server 2012 R2
4
+ platform:
5
+ - x64
6
+
7
+ environment:
8
+ winrm_user: test_user
9
+ winrm_pass: Pass@word1
10
+
11
+ matrix:
12
+ - ruby_version: "21"
13
+ winrm_endpoint: http://localhost:5985/wsman
14
+
15
+ clone_folder: c:\projects\winrm-fs
16
+ clone_depth: 1
17
+ branches:
18
+ only:
19
+ - master
20
+
21
+ install:
22
+ - ps: net user /add $env:winrm_user $env:winrm_pass
23
+ - ps: net localgroup administrators $env:winrm_user /add
24
+ - ps: winrm set winrm/config/client/auth '@{Basic="true"}'
25
+ - ps: winrm set winrm/config/service/auth '@{Basic="true"}'
26
+ - ps: winrm set winrm/config/service '@{AllowUnencrypted="true"}'
27
+ - ps: $env:PATH="C:\Ruby$env:ruby_version\bin;$env:PATH"
28
+ - ps: Write-Host $env:PATH
29
+ - ps: ruby --version
30
+ - ps: gem --version
31
+ - ps: gem install bundler --quiet --no-ri --no-rdoc
32
+ - ps: bundler --version
33
+
34
+ build_script:
35
+ - bundle install || bundle install || bundle install
36
+
37
+ test_script:
38
+ - SET SPEC_OPTS=--format progress
39
+ - bundle exec rake integration
data/changelog.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # WinRM-fs Gem Changelog
2
2
 
3
+ # 0.3.0
4
+ - 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:
5
+ - 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.
6
+ - `Upload` may now receive an array of source files and directories rather than just a single file or directory path.
7
+
8
+ # 0.2.4
9
+ - Fix issue 21, downloading files is extremely slow.
10
+ - Add zip file creation debug logging.
11
+
3
12
  # 0.2.3
4
13
  - Fix yielding progress data, issue #23
5
14
 
@@ -0,0 +1,506 @@
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