winrm-fs 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/changelog.md ADDED
@@ -0,0 +1 @@
1
+ 0.1.0 - Initial alpha quality release
data/lib/winrm-fs.rb ADDED
@@ -0,0 +1,27 @@
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_relative 'winrm-fs/exceptions'
20
+ require_relative 'winrm-fs/file_manager'
21
+
22
+ module WinRM
23
+ # WinRM File System
24
+ module FS
25
+ # Top level module code
26
+ end
27
+ end
@@ -0,0 +1,69 @@
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
+ module WinRM
18
+ module FS
19
+ module Core
20
+ # Executes commands used by the WinRM file management module
21
+ class CommandExecutor
22
+ def initialize(service)
23
+ @service = service
24
+ end
25
+
26
+ def open
27
+ @shell = @service.open_shell
28
+ @shell_open = true
29
+ end
30
+
31
+ def close
32
+ @service.close_shell(@shell) if @shell
33
+ @shell_open = false
34
+ end
35
+
36
+ def run_powershell(script)
37
+ assert_shell_is_open
38
+ run_cmd('powershell', ['-encodedCommand', encode_script(script)])
39
+ end
40
+
41
+ def run_cmd(command, arguments = [])
42
+ assert_shell_is_open
43
+ result = nil
44
+ @service.run_command(@shell, command, arguments) do |command_id|
45
+ result = @service.get_command_output(@shell, command_id)
46
+ end
47
+ assert_command_success(command, result)
48
+ result.stdout
49
+ end
50
+
51
+ private
52
+
53
+ def assert_shell_is_open
54
+ fail 'You must call open before calling any run methods' unless @shell_open
55
+ end
56
+
57
+ def assert_command_success(command, result)
58
+ return if result[:exitcode] == 0 && result.stderr.length == 0
59
+ fail WinRMUploadError, command + '\n' + result.output
60
+ end
61
+
62
+ def encode_script(script)
63
+ encoded_script = script.encode('UTF-16LE', 'UTF-8')
64
+ Base64.strict_encode64(encoded_script)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,84 @@
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_relative 'command_executor'
18
+
19
+ module WinRM
20
+ module FS
21
+ module Core
22
+ # Uploads the given source file to a temp file in 8k chunks
23
+ class FileUploader
24
+ def initialize(command_executor)
25
+ @command_executor = command_executor
26
+ end
27
+
28
+ # Uploads the given file to the specified temp file as base64 encoded.
29
+ #
30
+ # @param [String] Path to the local source file on this machine
31
+ # @param [String] Path to the file on the target machine
32
+ # @return [Integer] Count of bytes uploaded
33
+ def upload(local_file, remote_file)
34
+ @command_executor.run_powershell(prepare_script(remote_file))
35
+ do_upload(local_file, dos_path(remote_file))
36
+ end
37
+
38
+ private
39
+
40
+ def do_upload(local_file, remote_file)
41
+ bytes_copied = 0
42
+ base64_array = base64_content(local_file)
43
+ base64_array.each_slice(8000 - remote_file.size) do |chunk|
44
+ @command_executor.run_cmd("echo #{chunk.join} >> \"#{remote_file}\"")
45
+ bytes_copied += chunk.count
46
+ yield bytes_copied, base64_array.count if block_given?
47
+ end
48
+ base64_array.length
49
+ end
50
+
51
+ def base64_content(local_file)
52
+ base64_host_file = Base64.encode64(IO.binread(local_file)).gsub("\n", '')
53
+ base64_host_file.chars.to_a
54
+ end
55
+
56
+ def dos_path(path)
57
+ # TODO: convert all env vars
58
+ path = path.gsub(/\$env:TEMP/, '%TEMP%')
59
+ path.gsub(/\\/, '/')
60
+ end
61
+
62
+ # rubocop:disable Metrics/MethodLength
63
+ def prepare_script(remote_file)
64
+ <<-EOH
65
+ $p = $ExecutionContext.SessionState.Path
66
+ $path = $p.GetUnresolvedProviderPathFromPSPath("#{remote_file}")
67
+
68
+ # if the file exists, truncate it
69
+ if (Test-Path $path -PathType Leaf) {
70
+ '' | Set-Content $path
71
+ }
72
+
73
+ # ensure the target directory exists
74
+ $dir = [System.IO.Path]::GetDirectoryName($path)
75
+ if (!(Test-Path $dir -PathType Container)) {
76
+ mkdir $dir -ErrorAction SilentlyContinue | Out-Null
77
+ }
78
+ EOH
79
+ end
80
+ # rubocop:enable Metrics/MethodLength
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,95 @@
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 'zip'
18
+
19
+ module WinRM
20
+ module FS
21
+ module Core
22
+ # Temporary zip file on the local system
23
+ class TempZipFile
24
+ attr_reader :path
25
+
26
+ def initialize
27
+ @logger = Logging.logger[self]
28
+ @zip_file = Tempfile.new(['winrm_upload', '.zip'])
29
+ @zip_file.close
30
+ @path = @zip_file.path
31
+ end
32
+
33
+ # Adds a file or directory to the temporary zip file
34
+ # @param [String] Directory or file path to add into zip
35
+ def add(path)
36
+ if File.directory?(path)
37
+ add_directory(path)
38
+ elsif File.file?(path)
39
+ add_file(path)
40
+ else
41
+ fail "#{path} doesn't exist"
42
+ end
43
+ end
44
+
45
+ # Adds all files in the specified directory recursively into the zip file
46
+ # @param [String] Directory to add into zip
47
+ def add_directory(dir)
48
+ fail "#{dir} isn't a directory" unless File.directory?(dir)
49
+ glob = File.join(dir, '**/*')
50
+ Dir.glob(glob).each do |file|
51
+ add_file_entry(file, dir)
52
+ end
53
+ end
54
+
55
+ def add_file(file)
56
+ fail "#{file} isn't a file" unless File.file?(file)
57
+ add_file_entry(file, File.dirname(file))
58
+ end
59
+
60
+ def delete
61
+ @zip_file.delete
62
+ end
63
+
64
+ private
65
+
66
+ def add_file_entry(file, base_dir)
67
+ base_dir = "#{base_dir}/" unless base_dir.end_with?('/')
68
+ file_entry_path = file[base_dir.length..-1]
69
+ write_zip_entry(file, file_entry_path)
70
+ end
71
+
72
+ def write_zip_entry(file, file_entry_path)
73
+ @logger.debug("adding zip entry: #{file_entry_path}")
74
+ Zip::File.open(@path, 'w') do |zipfile|
75
+ entry = new_zip_entry(file_entry_path)
76
+ zipfile.add(entry, file)
77
+ end
78
+ end
79
+
80
+ def new_zip_entry(file_entry_path)
81
+ Zip::Entry.new(
82
+ @path,
83
+ file_entry_path,
84
+ nil,
85
+ nil,
86
+ nil,
87
+ nil,
88
+ nil,
89
+ nil,
90
+ ::Zip::DOSTime.new(2000))
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,118 @@
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_relative 'temp_zip_file'
18
+ require_relative 'file_uploader'
19
+ require_relative 'command_executor'
20
+ require_relative '../scripts/scripts'
21
+
22
+ module WinRM
23
+ module FS
24
+ module Core
25
+ # Orchestrates the upload of a file or directory
26
+ class UploadOrchestrator
27
+ def initialize(service)
28
+ @service = service
29
+ @logger = Logging.logger[self]
30
+ end
31
+
32
+ def upload_file(local_path, remote_path)
33
+ # If the src has a file extension and the destination does not
34
+ # we can assume the caller specified the dest as a directory
35
+ if File.extname(local_path) != '' && File.extname(remote_path) == ''
36
+ remote_path = File.join(remote_path, File.basename(local_path))
37
+ end
38
+ temp_path = temp_file_path(local_path)
39
+ with_command_executor do |cmd_executor|
40
+ return 0 unless out_of_date?(cmd_executor, local_path, remote_path)
41
+ do_file_upload(cmd_executor, local_path, temp_path, remote_path)
42
+ end
43
+ end
44
+
45
+ def upload_directory(local_paths, remote_path)
46
+ with_local_zip(local_paths) do |local_zip|
47
+ temp_path = temp_file_path(local_zip.path)
48
+ with_command_executor do |cmd_executor|
49
+ return 0 unless out_of_date?(cmd_executor, local_zip.path, temp_path)
50
+ do_file_upload(cmd_executor, local_zip.path, temp_path, remote_path)
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def do_file_upload(cmd_executor, local_path, temp_path, remote_path)
58
+ file_uploader = WinRM::FS::Core::FileUploader.new(cmd_executor)
59
+ bytes = file_uploader.upload(local_path, temp_path) do |bytes_copied, total_bytes|
60
+ yield bytes_copied, total_bytes, local_path, remote_path if block_given?
61
+ end
62
+
63
+ cmd_executor.run_powershell(
64
+ WinRM::FS::Scripts.render('decode_file', src: temp_path, dest: remote_path))
65
+
66
+ bytes
67
+ end
68
+
69
+ def with_command_executor
70
+ cmd_executor = WinRM::FS::Core::CommandExecutor.new(@service)
71
+ cmd_executor.open
72
+ yield cmd_executor
73
+ ensure
74
+ cmd_executor.close
75
+ end
76
+
77
+ def with_local_zip(local_paths)
78
+ local_zip = create_temp_zip_file(local_paths)
79
+ yield local_zip
80
+ ensure
81
+ local_zip.delete if local_zip
82
+ end
83
+
84
+ def out_of_date?(cmd_executor, local_path, remote_path)
85
+ local_checksum = local_checksum(local_path)
86
+ remote_checksum = remote_checksum(cmd_executor, remote_path)
87
+
88
+ if remote_checksum == local_checksum
89
+ @logger.debug("#{remote_path} is up to date")
90
+ return false
91
+ end
92
+ true
93
+ end
94
+
95
+ def remote_checksum(cmd_executor, remote_path)
96
+ script = WinRM::FS::Scripts.render('checksum', path: remote_path)
97
+ cmd_executor.run_powershell(script).chomp
98
+ end
99
+
100
+ def local_checksum(local_path)
101
+ Digest::MD5.file(local_path).hexdigest
102
+ end
103
+
104
+ def temp_file_path(local_path)
105
+ ext = '.tmp'
106
+ ext = '.zip' if File.extname(local_path) == '.zip'
107
+ "$env:TEMP/winrm-upload/#{local_checksum(local_path)}#{ext}"
108
+ end
109
+
110
+ def create_temp_zip_file(local_paths)
111
+ temp_zip = WinRM::FS::Core::TempZipFile.new
112
+ local_paths.each { |p| temp_zip.add(p) }
113
+ temp_zip
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +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
+ module WinRM
18
+ module FS
19
+ # WinRM-FS base class for errors
20
+ class WinRMFSError < StandardError; end
21
+
22
+ # Error that occurs when a file upload fails
23
+ class WinRMUploadError < WinRMFSError; end
24
+
25
+ # Error that occurs when a file download fails
26
+ class WinRMDownloadError < WinRMFSError; end
27
+ end
28
+ end
@@ -0,0 +1,123 @@
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_relative 'scripts/scripts'
18
+ require_relative 'core/upload_orchestrator'
19
+
20
+ module WinRM
21
+ module FS
22
+ # Perform file transfer operations between a local machine and winrm endpoint
23
+ class FileManager
24
+ # Creates a new FileManager instance
25
+ # @param [WinRMWebService] WinRM web service client
26
+ def initialize(service)
27
+ @service = service
28
+ @logger = Logging.logger[self]
29
+ end
30
+
31
+ # Gets the MD5 checksum of the specified file if it exists,
32
+ # otherwise ''
33
+ # @param [String] The remote file path
34
+ def checksum(path)
35
+ @logger.debug("checksum: #{path}")
36
+ script = WinRM::FS::Scripts.render('checksum', path: path)
37
+ @service.powershell(script).stdout.chomp
38
+ end
39
+
40
+ # Create the specifed directory recursively
41
+ # @param [String] The remote dir to create
42
+ # @return [Boolean] True if successful, otherwise false
43
+ def create_dir(path)
44
+ @logger.debug("create_dir: #{path}")
45
+ script = WinRM::FS::Scripts.render('create_dir', path: path)
46
+ @service.powershell(script)[:exitcode] == 0
47
+ end
48
+
49
+ # Deletes the file or directory at the specified path
50
+ # @param [String] The path to remove
51
+ # @return [Boolean] True if successful, otherwise False
52
+ def delete(path)
53
+ @logger.debug("deleting: #{path}")
54
+ script = WinRM::FS::Scripts.render('delete', path: path)
55
+ @service.powershell(script)[:exitcode] == 0
56
+ end
57
+
58
+ # Downloads the specified remote file to the specified local path
59
+ # @param [String] The full path on the remote machine
60
+ # @param [String] The full path to write the file to locally
61
+ def download(remote_path, local_path)
62
+ @logger.debug("downloading: #{remote_path} -> #{local_path}")
63
+ script = WinRM::FS::Scripts.render('download', path: remote_path)
64
+ output = @service.powershell(script)
65
+ return false if output[:exitcode] != 0
66
+ contents = output.stdout.gsub('\n\r', '')
67
+ out = Base64.decode64(contents)
68
+ IO.binwrite(local_path, out)
69
+ true
70
+ end
71
+
72
+ # Checks to see if the given path exists on the target file system.
73
+ # @param [String] The full path to the directory or file
74
+ # @return [Boolean] True if the file/dir exists, otherwise false.
75
+ def exists?(path)
76
+ @logger.debug("exists?: #{path}")
77
+ script = WinRM::FS::Scripts.render('exists', path: path)
78
+ @service.powershell(script)[:exitcode] == 0
79
+ end
80
+
81
+ # Gets the current user's TEMP directory on the remote system, for example
82
+ # 'C:/Windows/Temp'
83
+ # @return [String] Full path to the temp directory
84
+ def temp_dir
85
+ @guest_temp ||= (@service.cmd('echo %TEMP%')).stdout.chomp.gsub('\\', '/')
86
+ end
87
+
88
+ # Upload one or more local files and directories to a remote directory
89
+ # @example copy a single directory to a winrm endpoint
90
+ #
91
+ # file_manager.upload('c:/dev/my_dir', '$env:AppData')
92
+ #
93
+ # @example copy several paths to the winrm endpoint
94
+ #
95
+ # file_manager.upload(['c:/dev/file1.txt','c:/dev/dir1'], '$env:AppData')
96
+ #
97
+ # @param [Array<String>] One or more paths that will be copied to the remote path.
98
+ # These can be files or directories to be deeply copied
99
+ # @param [String] The target directory or file
100
+ # This path may contain powershell style environment variables
101
+ # @yieldparam [Fixnum] Number of bytes copied in current payload sent to the winrm endpoint
102
+ # @yieldparam [Fixnum] The total number of bytes to be copied
103
+ # @yieldparam [String] Path of file being copied
104
+ # @yieldparam [String] Target path on the winrm endpoint
105
+ # @return [Fixnum] The total number of bytes copied
106
+ def upload(local_paths, remote_path, &block)
107
+ @logger.debug("uploading: #{local_paths} -> #{remote_path}")
108
+ local_paths = [local_paths] if local_paths.is_a? String
109
+
110
+ upload_orchestrator = WinRM::FS::Core::UploadOrchestrator.new(@service)
111
+ if FileManager.src_is_single_file?(local_paths)
112
+ upload_orchestrator.upload_file(local_paths[0], remote_path, &block)
113
+ else
114
+ upload_orchestrator.upload_directory(local_paths, remote_path, &block)
115
+ end
116
+ end
117
+
118
+ def self.src_is_single_file?(local_paths)
119
+ local_paths.count == 1 && File.file?(local_paths[0])
120
+ end
121
+ end
122
+ end
123
+ end