spec/unit/file_transporter_spec.rb in winrm-fs-0.3.1 vs spec/unit/file_transporter_spec.rb in winrm-fs-0.3.2

- old
+ new

@@ -1,819 +1,819 @@ -# -*- encoding: utf-8 -*- -# -# Author:: Fletcher (<fnichol@nichol.ca>) -# -# Copyright (C) 2015, Fletcher Nichol -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require 'base64' -require 'csv' -require 'stringio' -require 'logger' -require 'winrm' - -require 'winrm-fs/core/file_transporter' - -describe WinRM::FS::Core::FileTransporter do - CheckEntry = Struct.new( - :chk_exists, :src_md5, :dst_md5, :chk_dirty, :verifies) - DecodeEntry = Struct.new( - :dst, :verifies, :src_md5, :dst_md5, :tmpfile, :tmpzip) - - let(:logged_output) { StringIO.new } - let(:logger) { Logger.new(logged_output) } - - let(:randomness) { %w(alpha beta charlie delta).each } - let(:id_generator) { -> { randomness.next } } - let(:winrm_service) { double('winrm_service', logger: logger) } - let(:service) { double('command_executor', service: winrm_service) } - let(:transporter) do - WinRM::FS::Core::FileTransporter.new( - service, - id_generator: id_generator - ) - end - - before { @tempfiles = [] } - - after { @tempfiles.each(&:unlink) } - - describe 'when uploading a single file' do - let(:content) { '.' * 12_003 } - let(:local) { create_tempfile('input.txt', content) } - let(:remote) { 'C:\\dest' } - let(:dst) { "#{remote}/#{File.basename(local)}" } - let(:src_md5) { md5sum(local) } - let(:size) { File.size(local) } - let(:cmd_tmpfile) { "%TEMP%\\b64-#{src_md5}.txt" } - let(:ps_tmpfile) { "$env:TEMP\\b64-#{src_md5}.txt" } - - let(:upload) { transporter.upload(local, remote) } - - # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - def self.common_specs_for_all_single_file_types - it 'truncates a zero-byte hash_file for check_files' do - expect(service).to receive(:run_cmd).with( - regexify(%(echo|set /p=>"%TEMP%\\hash-alpha.txt"))) - .and_return(cmd_output) - - upload - end - - it 'uploads the hash_file in chunks for check_files' do - hash = outdent!(<<-HASH.chomp) - @{ - "#{dst}" = "#{src_md5}" - } - HASH - - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-alpha.txt")) - .and_return(cmd_output).once - - upload - end - - it 'sets hash_file and runs the check_files powershell script' do - expect(service).to receive(:run_powershell_script).with( - regexify(%($hash_file = "$env:TEMP\\hash-alpha.txt")) && - regexify( - 'Check-Files (Invoke-Input $hash_file) | ' \ - 'ConvertTo-Csv -NoTypeInformation') - ).and_return(check_output) - - upload - end - end - # rubocop:enable Metrics/MethodLength, Metrics/AbcSize - - # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - def self.common_specs_for_all_single_dirty_file_types - it 'truncates a zero-byte tempfile' do - expect(service).to receive(:run_cmd).with( - regexify(%(echo|set /p=>"#{cmd_tmpfile}")) - ).and_return(cmd_output) - - upload - end - - it 'ploads the file in 8k chunks' do - expect(service).to receive(:run_cmd) - .with(%(echo #{base64('.' * 6000)} >> "#{cmd_tmpfile}")) - .and_return(cmd_output).twice - expect(service).to receive(:run_cmd) - .with(%(echo #{base64('.' * 3)} >> "#{cmd_tmpfile}")) - .and_return(cmd_output).once - - upload - end - - describe 'with a small file' do - let(:content) { 'hello, world' } - - it 'uploads the file in base64 encoding' do - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(content)} >> "#{cmd_tmpfile}")) - .and_return(cmd_output) - - upload - end - end - - it 'truncates a zero-byte hash_file for decode_files' do - expect(service).to receive(:run_cmd).with( - regexify(%(echo|set /p=>"%TEMP%\\hash-beta.txt")) - ).and_return(cmd_output) - - upload - end - - it 'uploads the hash_file in chunks for decode_files' do - hash = outdent!(<<-HASH.chomp) - @{ - "#{ps_tmpfile}" = @{ - "dst" = "#{dst}" - } - } - HASH - - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-beta.txt")) - .and_return(cmd_output).once - - upload - end - - it 'sets hash_file and runs the decode_files powershell script' do - expect(service).to receive(:run_powershell_script).with( - regexify(%($hash_file = "$env:TEMP\\hash-beta.txt")) && - regexify( - 'Decode-Files (Invoke-Input $hash_file) | ' \ - 'ConvertTo-Csv -NoTypeInformation') - ).and_return(check_output) - - upload - end - end - # rubocop:enable Metrics/MethodLength, Metrics/AbcSize - - describe 'for a new file' do - # let(:check_output) do - def check_output - create_check_output([ - CheckEntry.new('False', src_md5, nil, 'True', 'False') - ]) - end - - let(:cmd_output) do - o = ::WinRM::Output.new - o[:exitcode] = 0 - o - end - - # let(:decode_output) do - def decode_output - create_decode_output([ - DecodeEntry.new(dst, 'True', src_md5, src_md5, ps_tmpfile, nil) - ]) - end - - before do - allow(service).to receive(:run_cmd) - .and_return(cmd_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Check-Files .+ \| ConvertTo-Csv/) - .and_return(check_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Decode-Files .+ \| ConvertTo-Csv/) - .and_return(decode_output) - end - - common_specs_for_all_single_file_types - - common_specs_for_all_single_dirty_file_types - - it 'returns a report hash' do - expect(upload[1]).to eq( - src_md5 => { - 'src' => local, - 'dst' => dst, - 'tmpfile' => ps_tmpfile, - 'tmpzip' => nil, - 'src_md5' => src_md5, - 'dst_md5' => src_md5, - 'chk_exists' => 'False', - 'chk_dirty' => 'True', - 'verifies' => 'True', - 'size' => size, - 'xfered' => size / 3 * 4, - 'chunks' => (size / 6000.to_f).ceil - } - ) - end - - describe 'when a failed check command is returned' do - def check_output - o = ::WinRM::Output.new - o[:exitcode] = 10 - o[:data].concat([{ stderr: 'Oh noes\n' }]) - o - end - - it 'raises a FileTransporterFailed error' do - expect { upload }.to raise_error( - WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) - end - end - - describe 'when a failed decode command is returned' do - def decode_output - o = ::WinRM::Output.new - o[:exitcode] = 10 - o[:data].concat([{ stderr: 'Oh noes\n' }]) - o - end - - it 'raises a FileTransporterFailed error' do - expect { upload }.to raise_error( - WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) - end - end - end - - describe 'for an out of date (dirty) file' do - let(:check_output) do - create_check_output([ - CheckEntry.new('True', src_md5, 'aabbcc', 'True', 'False') - ]) - end - - let(:cmd_output) do - o = ::WinRM::Output.new - o[:exitcode] = 0 - o - end - - let(:decode_output) do - create_decode_output([ - DecodeEntry.new(dst, 'True', src_md5, src_md5, ps_tmpfile, nil) - ]) - end - - before do - allow(service).to receive(:run_cmd) - .and_return(cmd_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Check-Files .+ \| ConvertTo-Csv/) - .and_return(check_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Decode-Files .+ \| ConvertTo-Csv/) - .and_return(decode_output) - end - - common_specs_for_all_single_file_types - - common_specs_for_all_single_dirty_file_types - - it 'returns a report hash' do - expect(upload[1]).to eq( - src_md5 => { - 'src' => local, - 'dst' => dst, - 'tmpfile' => ps_tmpfile, - 'tmpzip' => nil, - 'src_md5' => src_md5, - 'dst_md5' => src_md5, - 'chk_exists' => 'True', - 'chk_dirty' => 'True', - 'verifies' => 'True', - 'size' => size, - 'xfered' => size / 3 * 4, - 'chunks' => (size / 6000.to_f).ceil - } - ) - end - end - - describe 'for an up to date (clean) file' do - let(:check_output) do - create_check_output([ - CheckEntry.new('True', src_md5, src_md5, 'False', 'True') - ]) - end - - let(:cmd_output) do - o = ::WinRM::Output.new - o[:exitcode] = 0 - o - end - - before do - allow(service).to receive(:run_cmd) - .and_return(cmd_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Check-Files .+ \| ConvertTo-Csv/) - .and_return(check_output) - end - - common_specs_for_all_single_file_types - - it 'uploads nothing' do - expect(service).not_to receive(:run_cmd).with(/#{remote}/) - - upload - end - - it 'skips the decode_files powershell script' do - expect(service).not_to receive(:run_powershell_script).with(regexify( - 'Decode-Files $files | ConvertTo-Csv -NoTypeInformation') - ) - - upload - end - - it 'returns a report hash' do - expect(upload[1]).to eq( - src_md5 => { - 'src' => local, - 'dst' => dst, - 'size' => size, - 'src_md5' => src_md5, - 'dst_md5' => src_md5, - 'chk_exists' => 'True', - 'chk_dirty' => 'False', - 'verifies' => 'True' - } - ) - end - end - end - - describe 'when uploading a single directory' do - let(:content) { "I'm a fake zip file" } - let(:local) { Dir.mktmpdir('input') } - let(:remote) { 'C:\\dest' } - let(:src_zip) { create_tempfile('fake.zip', content) } - let(:dst) { remote } - let(:src_md5) { md5sum(src_zip) } - let(:size) { File.size(src_zip) } - let(:cmd_tmpfile) { "%TEMP%\\b64-#{src_md5}.txt" } - let(:ps_tmpfile) { "$env:TEMP\\b64-#{src_md5}.txt" } - let(:ps_tmpzip) { "$env:TEMP\\winrm-upload\\tmpzip-#{src_md5}.zip" } - - let(:tmp_zip) { double('tmp_zip') } - - let(:cmd_output) do - o = ::WinRM::Output.new - o[:exitcode] = 0 - o - end - - let(:check_output) do - create_check_output([ - CheckEntry.new('False', src_md5, nil, 'True', 'False') - ]) - end - - let(:decode_output) do - create_decode_output([ - DecodeEntry.new(dst, 'True', src_md5, src_md5, ps_tmpfile, ps_tmpzip) - ]) - end - - before do - allow(tmp_zip).to receive(:path).and_return(Pathname(src_zip)) - allow(tmp_zip).to receive(:unlink) - allow(WinRM::FS::Core::TmpZip).to receive(:new).with("#{local}/", logger) - .and_return(tmp_zip) - - allow(service).to receive(:run_cmd) - .and_return(cmd_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Check-Files .+ \| ConvertTo-Csv/) - .and_return(check_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Decode-Files .+ \| ConvertTo-Csv/) - .and_return(decode_output) - end - - after do - FileUtils.rm_rf(local) - end - - let(:upload) { transporter.upload("#{local}/", remote) } - - it 'truncates a zero-byte hash_file for check_files' do - expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-alpha.txt")) - ).and_return(cmd_output) - - upload - end - - it 'uploads the hash_file in chunks for check_files' do - hash = outdent!(<<-HASH.chomp) - @{ - "#{ps_tmpzip}" = "#{src_md5}" - } - HASH - - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-alpha.txt")) - .and_return(cmd_output).once - - upload - end - - it 'sets hash_file and runs the check_files powershell script' do - expect(service).to receive(:run_powershell_script).with( - regexify(%($hash_file = "$env:TEMP\\hash-alpha.txt")) && - regexify( - 'Check-Files (Invoke-Input $hash_file) | ' \ - 'ConvertTo-Csv -NoTypeInformation') - ).and_return(check_output) - - upload - end - - it 'truncates a zero-byte tempfile' do - expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"#{cmd_tmpfile}")) - ).and_return(cmd_output) - - upload - end - - it 'uploads the zip file in base64 encoding' do - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(content)} >> "#{cmd_tmpfile}")) - .and_return(cmd_output) - - upload - end - - it 'truncates a zero-byte hash_file for decode_files' do - expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-beta.txt")) - ).and_return(cmd_output) - - upload - end - - it 'uploads the hash_file in chunks for decode_files' do - hash = outdent!(<<-HASH.chomp) - @{ - "#{ps_tmpfile}" = @{ - "dst" = "#{dst}\\#{File.basename(local)}"; - "tmpzip" = "#{ps_tmpzip}" - } - } - HASH - - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-beta.txt")) - .and_return(cmd_output).once - - upload - end - - it 'sets hash_file and runs the decode_files powershell script' do - expect(service).to receive(:run_powershell_script).with( - regexify(%($hash_file = "$env:TEMP\\hash-beta.txt")) && - regexify( - 'Decode-Files (Invoke-Input $hash_file) | ' \ - 'ConvertTo-Csv -NoTypeInformation') - ).and_return(check_output) - - upload - end - - it 'returns a report hash' do - expect(upload[1]).to eq( - src_md5 => { - 'src' => "#{local}/", - 'src_zip' => src_zip, - 'dst' => dst, - 'tmpfile' => ps_tmpfile, - 'tmpzip' => ps_tmpzip, - 'src_md5' => src_md5, - 'dst_md5' => src_md5, - 'chk_exists' => 'False', - 'chk_dirty' => 'True', - 'verifies' => 'True', - 'size' => size, - 'xfered' => size / 3 * 4, - 'chunks' => (size / 6000.to_f).ceil - } - ) - end - - it 'cleans up the zip file' do - expect(tmp_zip).to receive(:unlink) - - upload - end - - describe 'when a failed check command is returned' do - def check_output - o = ::WinRM::Output.new - o[:exitcode] = 10 - o[:data].concat([{ stderr: 'Oh noes\n' }]) - o - end - - it 'raises a FileTransporterFailed error' do - expect { upload }.to raise_error( - WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) - end - end - - describe 'when a failed decode command is returned' do - def decode_output - o = ::WinRM::Output.new - o[:exitcode] = 10 - o[:data].concat([{ stderr: 'Oh noes\n' }]) - o - end - - it 'raises a FileTransporterFailed error' do - expect { upload }.to raise_error( - WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) - end - end - end - - describe 'when uploading multiple files' do - let(:remote) { 'C:\\Program Files' } - - 1.upto(3).each do |i| - let(:"local#{i}") { create_tempfile("input#{i}.txt", "input#{i}") } - let(:"src#{i}_md5") { md5sum(send("local#{i}")) } - let(:"dst#{i}") { "#{remote}/#{File.basename(send("local#{i}"))}" } - let(:"size#{i}") { File.size(send("local#{i}")) } - let(:"cmd#{i}_tmpfile") { "%TEMP%\\b64-#{send("src#{i}_md5")}.txt" } - let(:"ps#{i}_tmpfile") { "$env:TEMP\\b64-#{send("src#{i}_md5")}.txt" } - end - - let(:check_output) do - create_check_output([ - # new - CheckEntry.new('False', src1_md5, nil, 'True', 'False'), - # out-of-date - CheckEntry.new('True', src2_md5, 'aabbcc', 'True', 'False'), - # current - CheckEntry.new('True', src3_md5, src3_md5, 'False', 'True') - ]) - end - - let(:cmd_output) do - o = ::WinRM::Output.new - o[:exitcode] = 0 - o - end - - let(:decode_output) do - create_decode_output([ - DecodeEntry.new(dst1, 'True', src1_md5, src1_md5, ps1_tmpfile, nil), - DecodeEntry.new(dst2, 'True', src2_md5, src2_md5, ps2_tmpfile, nil) - ]) - end - - let(:upload) { transporter.upload([local1, local2, local3], remote) } - - before do - allow(service).to receive(:run_cmd) - .and_return(cmd_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Check-Files .+ \| ConvertTo-Csv/) - .and_return(check_output) - - allow(service).to receive(:run_powershell_script) - .with(/^Decode-Files .+ \| ConvertTo-Csv/) - .and_return(decode_output) - end - - it 'truncates a zero-byte hash_file for check_files' do - expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-alpha.txt")) - ).and_return(cmd_output) - - upload - end - - it 'uploads the hash_file in chunks for check_files' do - hash = outdent!(<<-HASH.chomp) - @{ - "#{dst1}" = "#{src1_md5}"; - "#{dst2}" = "#{src2_md5}"; - "#{dst3}" = "#{src3_md5}" - } - HASH - - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-alpha.txt")) - .and_return(cmd_output).once - - upload - end - - it 'sets hash_file and runs the check_files powershell script' do - expect(service).to receive(:run_powershell_script).with( - regexify(%($hash_file = "$env:TEMP\\hash-alpha.txt")) && - regexify( - 'Check-Files (Invoke-Input $hash_file) | ' \ - 'ConvertTo-Csv -NoTypeInformation') - ).and_return(check_output) - - upload - end - - it 'only uploads dirty files' do - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(IO.read(local1))} >> "#{cmd1_tmpfile}")) - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(IO.read(local2))} >> "#{cmd2_tmpfile}")) - expect(service).not_to receive(:run_cmd) - .with(%(echo #{base64(IO.read(local3))} >> "#{cmd3_tmpfile}")) - - upload - end - - it 'truncates a zero-byte hash_file for decode_files' do - expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-beta.txt")) - ).and_return(cmd_output) - - upload - end - - it 'uploads the hash_file in chunks for decode_files' do - hash = outdent!(<<-HASH.chomp) - @{ - "#{ps1_tmpfile}" = @{ - "dst" = "#{dst1}" - }; - "#{ps2_tmpfile}" = @{ - "dst" = "#{dst2}" - } - } - HASH - - expect(service).to receive(:run_cmd) - .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-beta.txt")) - .and_return(cmd_output).once - - upload - end - - it 'sets hash_file and runs the decode_files powershell script' do - expect(service).to receive(:run_powershell_script).with( - regexify(%($hash_file = '$env:TEMP\\hash-beta.txt')) && - regexify( - 'Decode-Files (Invoke-Input $hash_file) | ' \ - 'ConvertTo-Csv -NoTypeInformation') - ).and_return(check_output) - - upload - end - - it 'returns a report hash' do - report = upload[1] - - expect(report.fetch(src1_md5)).to eq( - 'src' => local1, - 'dst' => dst1, - 'tmpfile' => ps1_tmpfile, - 'tmpzip' => nil, - 'src_md5' => src1_md5, - 'dst_md5' => src1_md5, - 'chk_exists' => 'False', - 'chk_dirty' => 'True', - 'verifies' => 'True', - 'size' => size1, - 'xfered' => size1 / 3 * 4, - 'chunks' => (size1 / 6000.to_f).ceil - ) - expect(report.fetch(src2_md5)).to eq( - 'src' => local2, - 'dst' => dst2, - 'tmpfile' => ps2_tmpfile, - 'tmpzip' => nil, - 'src_md5' => src2_md5, - 'dst_md5' => src2_md5, - 'chk_exists' => 'True', - 'chk_dirty' => 'True', - 'verifies' => 'True', - 'size' => size2, - 'xfered' => size2 / 3 * 4, - 'chunks' => (size2 / 6000.to_f).ceil - ) - expect(report.fetch(src3_md5)).to eq( - 'src' => local3, - 'dst' => dst3, - 'src_md5' => src3_md5, - 'dst_md5' => src3_md5, - 'chk_exists' => 'True', - 'chk_dirty' => 'False', - 'verifies' => 'True', - 'size' => size3 - ) - end - - describe 'when a failed check command is returned' do - def check_output - o = ::WinRM::Output.new - o[:exitcode] = 10 - o[:data].concat([{ stderr: "Oh noes\n" }]) - o - end - - it 'raises a FileTransporterFailed error' do - expect { upload }.to raise_error( - WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) - end - end - - describe 'when a failed decode command is returned' do - def decode_output - o = ::WinRM::Output.new - o[:exitcode] = 10 - o[:data].concat([{ stderr: "Oh noes\n" }]) - o - end - - it 'raises a FileTransporterFailed error' do - expect { upload }.to raise_error( - WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) - end - end - end - - it 'raises an exception when local file or directory is not found' do - expect { transporter.upload('/a/b/c/nope', 'C:\\nopeland') }.to raise_error Errno::ENOENT - end - - def base64(string) - Base64.strict_encode64(string) - end - - def create_check_output(entries) - csv = CSV.generate(force_quotes: true) do |rows| - rows << CheckEntry.new.members.map(&:to_s) - entries.each { |entry| rows << entry.to_a } - end - - o = ::WinRM::Output.new - o[:exitcode] = 0 - o[:data].concat(csv.lines.map { |line| { stdout: line } }) - o - end - - def create_decode_output(entries) - csv = CSV.generate(force_quotes: true) do |rows| - rows << DecodeEntry.new.members.map(&:to_s) - entries.each { |entry| rows << entry.to_a } - end - - o = ::WinRM::Output.new - o[:exitcode] = 0 - o[:data].concat(csv.lines.map { |line| { stdout: line } }) - o - end - - def create_tempfile(name, content) - pre, _, ext = name.rpartition('.') - file = Tempfile.open(["#{pre}-", ".#{ext}"]) - @tempfiles << file - file.write(content) - file.close - file.path - end - - def md5sum(local) - Digest::MD5.file(local).hexdigest - end - - def outdent!(string) - string.gsub!(/^ {#{string.index(/[^ ]/)}}/, '') - end - - def regexify(str, line = :whole_line) - r = Regexp.escape(str) - r = "^#{r}$" if line == :whole_line - Regexp.new(r) - end -end +# -*- encoding: utf-8 -*- +# +# Author:: Fletcher (<fnichol@nichol.ca>) +# +# Copyright (C) 2015, Fletcher Nichol +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'base64' +require 'csv' +require 'stringio' +require 'logger' +require 'winrm' + +require 'winrm-fs/core/file_transporter' + +describe WinRM::FS::Core::FileTransporter do + CheckEntry = Struct.new( + :chk_exists, :src_md5, :dst_md5, :chk_dirty, :verifies) + DecodeEntry = Struct.new( + :dst, :verifies, :src_md5, :dst_md5, :tmpfile, :tmpzip) + + let(:logged_output) { StringIO.new } + let(:logger) { Logger.new(logged_output) } + + let(:randomness) { %w(alpha beta charlie delta).each } + let(:id_generator) { -> { randomness.next } } + let(:winrm_service) { double('winrm_service', logger: logger) } + let(:service) { double('command_executor', service: winrm_service) } + let(:transporter) do + WinRM::FS::Core::FileTransporter.new( + service, + id_generator: id_generator + ) + end + + before { @tempfiles = [] } + + after { @tempfiles.each(&:unlink) } + + describe 'when uploading a single file' do + let(:content) { '.' * 12_003 } + let(:local) { create_tempfile('input.txt', content) } + let(:remote) { 'C:\\dest' } + let(:dst) { "#{remote}/#{File.basename(local)}" } + let(:src_md5) { md5sum(local) } + let(:size) { File.size(local) } + let(:cmd_tmpfile) { "%TEMP%\\b64-#{src_md5}.txt" } + let(:ps_tmpfile) { "$env:TEMP\\b64-#{src_md5}.txt" } + + let(:upload) { transporter.upload(local, remote) } + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def self.common_specs_for_all_single_file_types + it 'truncates a zero-byte hash_file for check_files' do + expect(service).to receive(:run_cmd).with( + regexify(%(echo|set /p=>"%TEMP%\\hash-alpha.txt"))) + .and_return(cmd_output) + + upload + end + + it 'uploads the hash_file in chunks for check_files' do + hash = outdent!(<<-HASH.chomp) + @{ + "#{dst}" = "#{src_md5}" + } + HASH + + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-alpha.txt")) + .and_return(cmd_output).once + + upload + end + + it 'sets hash_file and runs the check_files powershell script' do + expect(service).to receive(:run_powershell_script).with( + regexify(%($hash_file = "$env:TEMP\\hash-alpha.txt")) && + regexify( + 'Check-Files (Invoke-Input $hash_file) | ' \ + 'ConvertTo-Csv -NoTypeInformation') + ).and_return(check_output) + + upload + end + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def self.common_specs_for_all_single_dirty_file_types + it 'truncates a zero-byte tempfile' do + expect(service).to receive(:run_cmd).with( + regexify(%(echo|set /p=>"#{cmd_tmpfile}")) + ).and_return(cmd_output) + + upload + end + + it 'ploads the file in 8k chunks' do + expect(service).to receive(:run_cmd) + .with(%(echo #{base64('.' * 6000)} >> "#{cmd_tmpfile}")) + .and_return(cmd_output).twice + expect(service).to receive(:run_cmd) + .with(%(echo #{base64('.' * 3)} >> "#{cmd_tmpfile}")) + .and_return(cmd_output).once + + upload + end + + describe 'with a small file' do + let(:content) { 'hello, world' } + + it 'uploads the file in base64 encoding' do + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(content)} >> "#{cmd_tmpfile}")) + .and_return(cmd_output) + + upload + end + end + + it 'truncates a zero-byte hash_file for decode_files' do + expect(service).to receive(:run_cmd).with( + regexify(%(echo|set /p=>"%TEMP%\\hash-beta.txt")) + ).and_return(cmd_output) + + upload + end + + it 'uploads the hash_file in chunks for decode_files' do + hash = outdent!(<<-HASH.chomp) + @{ + "#{ps_tmpfile}" = @{ + "dst" = "#{dst}" + } + } + HASH + + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-beta.txt")) + .and_return(cmd_output).once + + upload + end + + it 'sets hash_file and runs the decode_files powershell script' do + expect(service).to receive(:run_powershell_script).with( + regexify(%($hash_file = "$env:TEMP\\hash-beta.txt")) && + regexify( + 'Decode-Files (Invoke-Input $hash_file) | ' \ + 'ConvertTo-Csv -NoTypeInformation') + ).and_return(check_output) + + upload + end + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + describe 'for a new file' do + # let(:check_output) do + def check_output + create_check_output([ + CheckEntry.new('False', src_md5, nil, 'True', 'False') + ]) + end + + let(:cmd_output) do + o = ::WinRM::Output.new + o[:exitcode] = 0 + o + end + + # let(:decode_output) do + def decode_output + create_decode_output([ + DecodeEntry.new(dst, 'True', src_md5, src_md5, ps_tmpfile, nil) + ]) + end + + before do + allow(service).to receive(:run_cmd) + .and_return(cmd_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Check-Files .+ \| ConvertTo-Csv/) + .and_return(check_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Decode-Files .+ \| ConvertTo-Csv/) + .and_return(decode_output) + end + + common_specs_for_all_single_file_types + + common_specs_for_all_single_dirty_file_types + + it 'returns a report hash' do + expect(upload[1]).to eq( + src_md5 => { + 'src' => local, + 'dst' => dst, + 'tmpfile' => ps_tmpfile, + 'tmpzip' => nil, + 'src_md5' => src_md5, + 'dst_md5' => src_md5, + 'chk_exists' => 'False', + 'chk_dirty' => 'True', + 'verifies' => 'True', + 'size' => size, + 'xfered' => size / 3 * 4, + 'chunks' => (size / 6000.to_f).ceil + } + ) + end + + describe 'when a failed check command is returned' do + def check_output + o = ::WinRM::Output.new + o[:exitcode] = 10 + o[:data].concat([{ stderr: 'Oh noes\n' }]) + o + end + + it 'raises a FileTransporterFailed error' do + expect { upload }.to raise_error( + WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) + end + end + + describe 'when a failed decode command is returned' do + def decode_output + o = ::WinRM::Output.new + o[:exitcode] = 10 + o[:data].concat([{ stderr: 'Oh noes\n' }]) + o + end + + it 'raises a FileTransporterFailed error' do + expect { upload }.to raise_error( + WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) + end + end + end + + describe 'for an out of date (dirty) file' do + let(:check_output) do + create_check_output([ + CheckEntry.new('True', src_md5, 'aabbcc', 'True', 'False') + ]) + end + + let(:cmd_output) do + o = ::WinRM::Output.new + o[:exitcode] = 0 + o + end + + let(:decode_output) do + create_decode_output([ + DecodeEntry.new(dst, 'True', src_md5, src_md5, ps_tmpfile, nil) + ]) + end + + before do + allow(service).to receive(:run_cmd) + .and_return(cmd_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Check-Files .+ \| ConvertTo-Csv/) + .and_return(check_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Decode-Files .+ \| ConvertTo-Csv/) + .and_return(decode_output) + end + + common_specs_for_all_single_file_types + + common_specs_for_all_single_dirty_file_types + + it 'returns a report hash' do + expect(upload[1]).to eq( + src_md5 => { + 'src' => local, + 'dst' => dst, + 'tmpfile' => ps_tmpfile, + 'tmpzip' => nil, + 'src_md5' => src_md5, + 'dst_md5' => src_md5, + 'chk_exists' => 'True', + 'chk_dirty' => 'True', + 'verifies' => 'True', + 'size' => size, + 'xfered' => size / 3 * 4, + 'chunks' => (size / 6000.to_f).ceil + } + ) + end + end + + describe 'for an up to date (clean) file' do + let(:check_output) do + create_check_output([ + CheckEntry.new('True', src_md5, src_md5, 'False', 'True') + ]) + end + + let(:cmd_output) do + o = ::WinRM::Output.new + o[:exitcode] = 0 + o + end + + before do + allow(service).to receive(:run_cmd) + .and_return(cmd_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Check-Files .+ \| ConvertTo-Csv/) + .and_return(check_output) + end + + common_specs_for_all_single_file_types + + it 'uploads nothing' do + expect(service).not_to receive(:run_cmd).with(/#{remote}/) + + upload + end + + it 'skips the decode_files powershell script' do + expect(service).not_to receive(:run_powershell_script).with(regexify( + 'Decode-Files $files | ConvertTo-Csv -NoTypeInformation') + ) + + upload + end + + it 'returns a report hash' do + expect(upload[1]).to eq( + src_md5 => { + 'src' => local, + 'dst' => dst, + 'size' => size, + 'src_md5' => src_md5, + 'dst_md5' => src_md5, + 'chk_exists' => 'True', + 'chk_dirty' => 'False', + 'verifies' => 'True' + } + ) + end + end + end + + describe 'when uploading a single directory' do + let(:content) { "I'm a fake zip file" } + let(:local) { Dir.mktmpdir('input') } + let(:remote) { 'C:\\dest' } + let(:src_zip) { create_tempfile('fake.zip', content) } + let(:dst) { remote } + let(:src_md5) { md5sum(src_zip) } + let(:size) { File.size(src_zip) } + let(:cmd_tmpfile) { "%TEMP%\\b64-#{src_md5}.txt" } + let(:ps_tmpfile) { "$env:TEMP\\b64-#{src_md5}.txt" } + let(:ps_tmpzip) { "$env:TEMP\\winrm-upload\\tmpzip-#{src_md5}.zip" } + + let(:tmp_zip) { double('tmp_zip') } + + let(:cmd_output) do + o = ::WinRM::Output.new + o[:exitcode] = 0 + o + end + + let(:check_output) do + create_check_output([ + CheckEntry.new('False', src_md5, nil, 'True', 'False') + ]) + end + + let(:decode_output) do + create_decode_output([ + DecodeEntry.new(dst, 'True', src_md5, src_md5, ps_tmpfile, ps_tmpzip) + ]) + end + + before do + allow(tmp_zip).to receive(:path).and_return(Pathname(src_zip)) + allow(tmp_zip).to receive(:unlink) + allow(WinRM::FS::Core::TmpZip).to receive(:new).with("#{local}/", logger) + .and_return(tmp_zip) + + allow(service).to receive(:run_cmd) + .and_return(cmd_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Check-Files .+ \| ConvertTo-Csv/) + .and_return(check_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Decode-Files .+ \| ConvertTo-Csv/) + .and_return(decode_output) + end + + after do + FileUtils.rm_rf(local) + end + + let(:upload) { transporter.upload("#{local}/", remote) } + + it 'truncates a zero-byte hash_file for check_files' do + expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-alpha.txt")) + ).and_return(cmd_output) + + upload + end + + it 'uploads the hash_file in chunks for check_files' do + hash = outdent!(<<-HASH.chomp) + @{ + "#{ps_tmpzip}" = "#{src_md5}" + } + HASH + + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-alpha.txt")) + .and_return(cmd_output).once + + upload + end + + it 'sets hash_file and runs the check_files powershell script' do + expect(service).to receive(:run_powershell_script).with( + regexify(%($hash_file = "$env:TEMP\\hash-alpha.txt")) && + regexify( + 'Check-Files (Invoke-Input $hash_file) | ' \ + 'ConvertTo-Csv -NoTypeInformation') + ).and_return(check_output) + + upload + end + + it 'truncates a zero-byte tempfile' do + expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"#{cmd_tmpfile}")) + ).and_return(cmd_output) + + upload + end + + it 'uploads the zip file in base64 encoding' do + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(content)} >> "#{cmd_tmpfile}")) + .and_return(cmd_output) + + upload + end + + it 'truncates a zero-byte hash_file for decode_files' do + expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-beta.txt")) + ).and_return(cmd_output) + + upload + end + + it 'uploads the hash_file in chunks for decode_files' do + hash = outdent!(<<-HASH.chomp) + @{ + "#{ps_tmpfile}" = @{ + "dst" = "#{dst}\\#{File.basename(local)}"; + "tmpzip" = "#{ps_tmpzip}" + } + } + HASH + + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-beta.txt")) + .and_return(cmd_output).once + + upload + end + + it 'sets hash_file and runs the decode_files powershell script' do + expect(service).to receive(:run_powershell_script).with( + regexify(%($hash_file = "$env:TEMP\\hash-beta.txt")) && + regexify( + 'Decode-Files (Invoke-Input $hash_file) | ' \ + 'ConvertTo-Csv -NoTypeInformation') + ).and_return(check_output) + + upload + end + + it 'returns a report hash' do + expect(upload[1]).to eq( + src_md5 => { + 'src' => "#{local}/", + 'src_zip' => src_zip, + 'dst' => dst, + 'tmpfile' => ps_tmpfile, + 'tmpzip' => ps_tmpzip, + 'src_md5' => src_md5, + 'dst_md5' => src_md5, + 'chk_exists' => 'False', + 'chk_dirty' => 'True', + 'verifies' => 'True', + 'size' => size, + 'xfered' => size / 3 * 4, + 'chunks' => (size / 6000.to_f).ceil + } + ) + end + + it 'cleans up the zip file' do + expect(tmp_zip).to receive(:unlink) + + upload + end + + describe 'when a failed check command is returned' do + def check_output + o = ::WinRM::Output.new + o[:exitcode] = 10 + o[:data].concat([{ stderr: 'Oh noes\n' }]) + o + end + + it 'raises a FileTransporterFailed error' do + expect { upload }.to raise_error( + WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) + end + end + + describe 'when a failed decode command is returned' do + def decode_output + o = ::WinRM::Output.new + o[:exitcode] = 10 + o[:data].concat([{ stderr: 'Oh noes\n' }]) + o + end + + it 'raises a FileTransporterFailed error' do + expect { upload }.to raise_error( + WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) + end + end + end + + describe 'when uploading multiple files' do + let(:remote) { 'C:\\Program Files' } + + 1.upto(3).each do |i| + let(:"local#{i}") { create_tempfile("input#{i}.txt", "input#{i}") } + let(:"src#{i}_md5") { md5sum(send("local#{i}")) } + let(:"dst#{i}") { "#{remote}/#{File.basename(send("local#{i}"))}" } + let(:"size#{i}") { File.size(send("local#{i}")) } + let(:"cmd#{i}_tmpfile") { "%TEMP%\\b64-#{send("src#{i}_md5")}.txt" } + let(:"ps#{i}_tmpfile") { "$env:TEMP\\b64-#{send("src#{i}_md5")}.txt" } + end + + let(:check_output) do + create_check_output([ + # new + CheckEntry.new('False', src1_md5, nil, 'True', 'False'), + # out-of-date + CheckEntry.new('True', src2_md5, 'aabbcc', 'True', 'False'), + # current + CheckEntry.new('True', src3_md5, src3_md5, 'False', 'True') + ]) + end + + let(:cmd_output) do + o = ::WinRM::Output.new + o[:exitcode] = 0 + o + end + + let(:decode_output) do + create_decode_output([ + DecodeEntry.new(dst1, 'True', src1_md5, src1_md5, ps1_tmpfile, nil), + DecodeEntry.new(dst2, 'True', src2_md5, src2_md5, ps2_tmpfile, nil) + ]) + end + + let(:upload) { transporter.upload([local1, local2, local3], remote) } + + before do + allow(service).to receive(:run_cmd) + .and_return(cmd_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Check-Files .+ \| ConvertTo-Csv/) + .and_return(check_output) + + allow(service).to receive(:run_powershell_script) + .with(/^Decode-Files .+ \| ConvertTo-Csv/) + .and_return(decode_output) + end + + it 'truncates a zero-byte hash_file for check_files' do + expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-alpha.txt")) + ).and_return(cmd_output) + + upload + end + + it 'uploads the hash_file in chunks for check_files' do + hash = outdent!(<<-HASH.chomp) + @{ + "#{dst1}" = "#{src1_md5}"; + "#{dst2}" = "#{src2_md5}"; + "#{dst3}" = "#{src3_md5}" + } + HASH + + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-alpha.txt")) + .and_return(cmd_output).once + + upload + end + + it 'sets hash_file and runs the check_files powershell script' do + expect(service).to receive(:run_powershell_script).with( + regexify(%($hash_file = "$env:TEMP\\hash-alpha.txt")) && + regexify( + 'Check-Files (Invoke-Input $hash_file) | ' \ + 'ConvertTo-Csv -NoTypeInformation') + ).and_return(check_output) + + upload + end + + it 'only uploads dirty files' do + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(IO.read(local1))} >> "#{cmd1_tmpfile}")) + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(IO.read(local2))} >> "#{cmd2_tmpfile}")) + expect(service).not_to receive(:run_cmd) + .with(%(echo #{base64(IO.read(local3))} >> "#{cmd3_tmpfile}")) + + upload + end + + it 'truncates a zero-byte hash_file for decode_files' do + expect(service).to receive(:run_cmd).with(regexify(%(echo|set /p=>"%TEMP%\\hash-beta.txt")) + ).and_return(cmd_output) + + upload + end + + it 'uploads the hash_file in chunks for decode_files' do + hash = outdent!(<<-HASH.chomp) + @{ + "#{ps1_tmpfile}" = @{ + "dst" = "#{dst1}" + }; + "#{ps2_tmpfile}" = @{ + "dst" = "#{dst2}" + } + } + HASH + + expect(service).to receive(:run_cmd) + .with(%(echo #{base64(hash)} >> "%TEMP%\\hash-beta.txt")) + .and_return(cmd_output).once + + upload + end + + it 'sets hash_file and runs the decode_files powershell script' do + expect(service).to receive(:run_powershell_script).with( + regexify(%($hash_file = '$env:TEMP\\hash-beta.txt')) && + regexify( + 'Decode-Files (Invoke-Input $hash_file) | ' \ + 'ConvertTo-Csv -NoTypeInformation') + ).and_return(check_output) + + upload + end + + it 'returns a report hash' do + report = upload[1] + + expect(report.fetch(src1_md5)).to eq( + 'src' => local1, + 'dst' => dst1, + 'tmpfile' => ps1_tmpfile, + 'tmpzip' => nil, + 'src_md5' => src1_md5, + 'dst_md5' => src1_md5, + 'chk_exists' => 'False', + 'chk_dirty' => 'True', + 'verifies' => 'True', + 'size' => size1, + 'xfered' => size1 / 3 * 4, + 'chunks' => (size1 / 6000.to_f).ceil + ) + expect(report.fetch(src2_md5)).to eq( + 'src' => local2, + 'dst' => dst2, + 'tmpfile' => ps2_tmpfile, + 'tmpzip' => nil, + 'src_md5' => src2_md5, + 'dst_md5' => src2_md5, + 'chk_exists' => 'True', + 'chk_dirty' => 'True', + 'verifies' => 'True', + 'size' => size2, + 'xfered' => size2 / 3 * 4, + 'chunks' => (size2 / 6000.to_f).ceil + ) + expect(report.fetch(src3_md5)).to eq( + 'src' => local3, + 'dst' => dst3, + 'src_md5' => src3_md5, + 'dst_md5' => src3_md5, + 'chk_exists' => 'True', + 'chk_dirty' => 'False', + 'verifies' => 'True', + 'size' => size3 + ) + end + + describe 'when a failed check command is returned' do + def check_output + o = ::WinRM::Output.new + o[:exitcode] = 10 + o[:data].concat([{ stderr: "Oh noes\n" }]) + o + end + + it 'raises a FileTransporterFailed error' do + expect { upload }.to raise_error( + WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) + end + end + + describe 'when a failed decode command is returned' do + def decode_output + o = ::WinRM::Output.new + o[:exitcode] = 10 + o[:data].concat([{ stderr: "Oh noes\n" }]) + o + end + + it 'raises a FileTransporterFailed error' do + expect { upload }.to raise_error( + WinRM::FS::Core::FileTransporterFailed, /Upload failed \(exitcode: 10\)/) + end + end + end + + it 'raises an exception when local file or directory is not found' do + expect { transporter.upload('/a/b/c/nope', 'C:\\nopeland') }.to raise_error Errno::ENOENT + end + + def base64(string) + Base64.strict_encode64(string) + end + + def create_check_output(entries) + csv = CSV.generate(force_quotes: true) do |rows| + rows << CheckEntry.new.members.map(&:to_s) + entries.each { |entry| rows << entry.to_a } + end + + o = ::WinRM::Output.new + o[:exitcode] = 0 + o[:data].concat(csv.lines.map { |line| { stdout: line } }) + o + end + + def create_decode_output(entries) + csv = CSV.generate(force_quotes: true) do |rows| + rows << DecodeEntry.new.members.map(&:to_s) + entries.each { |entry| rows << entry.to_a } + end + + o = ::WinRM::Output.new + o[:exitcode] = 0 + o[:data].concat(csv.lines.map { |line| { stdout: line } }) + o + end + + def create_tempfile(name, content) + pre, _, ext = name.rpartition('.') + file = Tempfile.open(["#{pre}-", ".#{ext}"]) + @tempfiles << file + file.write(content) + file.close + file.path + end + + def md5sum(local) + Digest::MD5.file(local).hexdigest + end + + def outdent!(string) + string.gsub!(/^ {#{string.index(/[^ ]/)}}/, '') + end + + def regexify(str, line = :whole_line) + r = Regexp.escape(str) + r = "^#{r}$" if line == :whole_line + Regexp.new(r) + end +end