winrm-fs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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