summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorDaniel DeLeo <dan@opscode.com>2011-12-01 11:51:25 -0800
committerDaniel DeLeo <dan@opscode.com>2011-12-01 11:51:25 -0800
commit1f7734e92f30c0b6545bcc8b537e920e3f12b6bc (patch)
tree0677cc613ef17454cef35c9ce0ad07f50db28603 /lib
downloadmixlib-shellout-1f7734e92f30c0b6545bcc8b537e920e3f12b6bc.tar.gz
Initial extraction of ShellOut from Chef
Diffstat (limited to 'lib')
-rw-r--r--lib/mixlib/shellout.rb237
-rw-r--r--lib/mixlib/shellout/exceptions.rb8
-rw-r--r--lib/mixlib/shellout/unix.rb223
-rw-r--r--lib/mixlib/shellout/version.rb5
-rw-r--r--lib/mixlib/shellout/windows.rb554
5 files changed, 1027 insertions, 0 deletions
diff --git a/lib/mixlib/shellout.rb b/lib/mixlib/shellout.rb
new file mode 100644
index 0000000..88a666d
--- /dev/null
+++ b/lib/mixlib/shellout.rb
@@ -0,0 +1,237 @@
+#--
+# Author:: Daniel DeLeo (<dan@opscode.com>)
+# Copyright:: Copyright (c) 2010 Opscode, Inc.
+# License:: Apache License, Version 2.0
+#
+# 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 'etc'
+require 'tmpdir'
+require 'chef/log'
+require 'fcntl'
+require 'mixlib/shellout/exceptions'
+
+module Mixlib
+
+ class ShellOut
+ READ_WAIT_TIME = 0.01
+ READ_SIZE = 4096
+ DEFAULT_READ_TIMEOUT = 600
+ DEFAULT_ENVIRONMENT = {'LC_ALL' => 'C'}
+
+ if RUBY_PLATFORM =~ /mswin|mingw32|windows/
+ require 'mixlib/shellout/windows'
+ include ShellOut::Windows
+ else
+ require 'mixlib/shellout/unix'
+ include ShellOut::Unix
+ end
+
+ attr_accessor :user
+ attr_accessor :group
+ attr_accessor :cwd
+ attr_accessor :valid_exit_codes
+ attr_accessor :live_stream
+ attr_accessor :command_log_level
+ attr_accessor :command_log_prepend
+
+ attr_reader :command, :umask, :environment
+ attr_writer :timeout
+ attr_reader :execution_time
+
+ attr_reader :stdout, :stderr, :status
+
+ attr_reader :stdin_pipe, :stdout_pipe, :stderr_pipe, :process_status_pipe
+
+ # === Arguments:
+ # Takes a single command, or a list of command fragments. These are used
+ # as arguments to Kernel.exec. See the Kernel.exec documentation for more
+ # explanation of how arguments are evaluated. The last argument can be an
+ # options Hash.
+ # === Options:
+ # If the last argument is a Hash, it is removed from the list of args passed
+ # to exec and used as an options hash. The following options are available:
+ # * +user+: the user the commmand should run as. if an integer is given, it is
+ # used as a uid. A string is treated as a username and resolved to a uid
+ # with Etc.getpwnam
+ # * +group+: the group the command should run as. works similarly to +user+
+ # * +cwd+: the directory to chdir to before running the command
+ # * +umask+: a umask to set before running the command. If given as an Integer,
+ # be sure to use two leading zeros so it's parsed as Octal. A string will
+ # be treated as an octal integer
+ # * +returns+: one or more Integer values to use as valid exit codes for the
+ # subprocess. This only has an effect if you call +error!+ after
+ # +run_command+.
+ # * +environment+: a Hash of environment variables to set before the command
+ # is run. By default, the environment will *always* be set to 'LC_ALL' => 'C'
+ # to prevent issues with multibyte characters in Ruby 1.8. To avoid this,
+ # use :environment => nil for *no* extra environment settings, or
+ # :environment => {'LC_ALL'=>nil, ...} to set other environment settings
+ # without changing the locale.
+ # * +timeout+: a Numeric value for the number of seconds to wait on the
+ # child process before raising an Exception. This is calculated as the
+ # total amount of time that ShellOut waited on the child process without
+ # receiving any output (i.e., IO.select returned nil). Default is 60
+ # seconds. Note: the stdlib Timeout library is not used.
+ # === Examples:
+ # Invoke find(1) to search for .rb files:
+ # find = Chef::ShellOut.new("find . -name '*.rb'")
+ # find.run_command
+ # # If all went well, the results are on +stdout+
+ # puts find.stdout
+ # # find(1) prints diagnostic info to STDERR:
+ # puts "error messages" + find.stderr
+ # # Raise an exception if it didn't exit with 0
+ # find.error!
+ # Run a command as the +www+ user with no extra ENV settings from +/tmp+
+ # cmd = Chef::ShellOut.new("apachectl", "start", :user => 'www', :env => nil, :cwd => '/tmp')
+ # cmd.run_command # etc.
+ def initialize(*command_args)
+ @stdout, @stderr = '', ''
+ @live_stream = nil
+ @command_log_level = :debug
+ @command_log_prepend = nil
+ @environment = DEFAULT_ENVIRONMENT
+ @cwd = nil
+ @valid_exit_codes = [0]
+
+ if command_args.last.is_a?(Hash)
+ parse_options(command_args.pop)
+ end
+
+ @command = command_args.size == 1 ? command_args.first : command_args
+ end
+
+ def umask=(new_umask)
+ @umask = (new_umask.respond_to?(:oct) ? new_umask.oct : new_umask.to_i) & 007777
+ end
+
+ def uid
+ return nil unless user
+ user.kind_of?(Integer) ? user : Etc.getpwnam(user.to_s).uid
+ end
+
+ def gid
+ return nil unless group
+ group.kind_of?(Integer) ? group : Etc.getgrnam(group.to_s).gid
+ end
+
+ def timeout
+ @timeout || DEFAULT_READ_TIMEOUT
+ end
+
+ # Creates a String showing the output of the command, including a banner
+ # showing the exact command executed. Used by +invalid!+ to show command
+ # results when the command exited with an unexpected status.
+ def format_for_exception
+ msg = ""
+ msg << "---- Begin output of #{command} ----\n"
+ msg << "STDOUT: #{stdout.strip}\n"
+ msg << "STDERR: #{stderr.strip}\n"
+ msg << "---- End output of #{command} ----\n"
+ msg << "Ran #{command} returned #{status.exitstatus}" if status
+ msg
+ end
+
+ def exitstatus
+ @status && @status.exitstatus
+ end
+
+ # Run the command, writing the command's standard out and standard error
+ # to +stdout+ and +stderr+, and saving its exit status object to +status+
+ # === Returns
+ # returns +self+; +stdout+, +stderr+, +status+, and +exitstatus+ will be
+ # populated with results of the command
+ # === Raises
+ # * Errno::EACCES when you are not privileged to execute the command
+ # * Errno::ENOENT when the command is not available on the system (or not
+ # in the current $PATH)
+ # * ::CommandTimeout when the command does not complete
+ # within +timeout+ seconds (default: 60s)
+ def run_command
+ if command_log_prepend
+ Chef::Log.send(command_log_level, "#{command_log_prepend} sh(#{@command})")
+ else
+ Chef::Log.send(command_log_level, "sh(#{@command})")
+ end
+ super
+ end
+
+ # Checks the +exitstatus+ against the set of +valid_exit_codes+. If
+ # +exitstatus+ is not in the list of +valid_exit_codes+, calls +invalid!+,
+ # which raises an Exception.
+ # === Returns
+ # nil::: always returns nil when it does not raise
+ # === Raises
+ # ::ShellCommandFailed::: via +invalid!+
+ def error!
+ unless Array(valid_exit_codes).include?(exitstatus)
+ invalid!("Expected process to exit with #{valid_exit_codes.inspect}, but received '#{exitstatus}'")
+ end
+ end
+
+ # Raises a ::ShellCommandFailed exception, appending the
+ # command's stdout, stderr, and exitstatus to the exception message.
+ # === Arguments
+ # +msg+: A String to use as the basis of the exception message. The
+ # default explanation is very generic, providing a more informative message
+ # is highly encouraged.
+ # === Raises
+ # ::ShellCommandFailed always
+ def invalid!(msg=nil)
+ msg ||= "Command produced unexpected results"
+ raise ShellCommandFailed, msg + "\n" + format_for_exception
+ end
+
+ def inspect
+ "<#{self.class.name}##{object_id}: command: '#@command' process_status: #{@status.inspect} " +
+ "stdout: '#{stdout.strip}' stderr: '#{stderr.strip}' child_pid: #{@child_pid.inspect} " +
+ "environment: #{@environment.inspect} timeout: #{timeout} user: #@user group: #@group working_dir: #@cwd >"
+ end
+
+ private
+
+ def parse_options(opts)
+ opts.each do |option, setting|
+ case option.to_s
+ when 'cwd'
+ self.cwd = setting
+ when 'user'
+ self.user = setting
+ when 'group'
+ self.group = setting
+ when 'umask'
+ self.umask = setting
+ when 'timeout'
+ self.timeout = setting
+ when 'returns'
+ self.valid_exit_codes = Array(setting)
+ when 'live_stream'
+ self.live_stream = setting
+ when 'command_log_level'
+ self.command_log_level = setting
+ when 'command_log_prepend'
+ self.command_log_prepend = setting
+ when 'environment', 'env'
+ # passing :environment => nil means don't set any new ENV vars
+ @environment = setting.nil? ? {} : @environment.dup.merge!(setting)
+ else
+ raise InvalidCommandOption, "option '#{option.inspect}' is not a valid option for #{self.class.name}"
+ end
+ end
+ end
+
+
+ end
+end
diff --git a/lib/mixlib/shellout/exceptions.rb b/lib/mixlib/shellout/exceptions.rb
new file mode 100644
index 0000000..417def3
--- /dev/null
+++ b/lib/mixlib/shellout/exceptions.rb
@@ -0,0 +1,8 @@
+module Mixlib
+ class ShellOut
+ class ShellCommandFailed < RuntimeError; end
+ class CommandTimeout < RuntimeError; end
+ class InvalidCommandOption < RuntimeError; end
+ end
+end
+
diff --git a/lib/mixlib/shellout/unix.rb b/lib/mixlib/shellout/unix.rb
new file mode 100644
index 0000000..6e66b2a
--- /dev/null
+++ b/lib/mixlib/shellout/unix.rb
@@ -0,0 +1,223 @@
+module Mixlib
+ class ShellOut
+ module Unix
+
+ # Run the command, writing the command's standard out and standard error
+ # to +stdout+ and +stderr+, and saving its exit status object to +status+
+ # === Returns
+ # returns +self+; +stdout+, +stderr+, +status+, and +exitstatus+ will be
+ # populated with results of the command
+ # === Raises
+ # * Errno::EACCES when you are not privileged to execute the command
+ # * Errno::ENOENT when the command is not available on the system (or not
+ # in the current $PATH)
+ # * Chef::Exceptions::CommandTimeout when the command does not complete
+ # within +timeout+ seconds (default: 60s)
+ def run_command
+ @child_pid = fork_subprocess
+
+ configure_parent_process_file_descriptors
+ propagate_pre_exec_failure
+
+ @result = nil
+ @execution_time = 0
+
+ # Ruby 1.8.7 and 1.8.6 from mid 2009 try to allocate objects during GC
+ # when calling IO.select and IO#read. Some OS Vendors are not interested
+ # in updating their ruby packages (Apple, *cough*) and we *have to*
+ # make it work. So I give you this epic hack:
+ GC.disable
+ until @status
+ ready = IO.select(open_pipes, nil, nil, READ_WAIT_TIME)
+ unless ready
+ @execution_time += READ_WAIT_TIME
+ if @execution_time >= timeout && !@result
+ raise CommandTimeout, "command timed out:\n#{format_for_exception}"
+ end
+ end
+
+ if ready && ready.first.include?(child_stdout)
+ read_stdout_to_buffer
+ end
+ if ready && ready.first.include?(child_stderr)
+ read_stderr_to_buffer
+ end
+
+ unless @status
+ # make one more pass to get the last of the output after the
+ # child process dies
+ if results = Process.waitpid2(@child_pid, Process::WNOHANG)
+ @status = results.last
+ redo
+ end
+ end
+ end
+ self
+ rescue Exception
+ # do our best to kill zombies
+ Process.waitpid2(@child_pid, Process::WNOHANG) rescue nil
+ raise
+ ensure
+ # no matter what happens, turn the GC back on, and hope whatever busted
+ # version of ruby we're on doesn't allocate some objects during the next
+ # GC run.
+ GC.enable
+ close_all_pipes
+ end
+
+ private
+
+ def set_user
+ if user
+ Process.euid = uid
+ Process.uid = uid
+ end
+ end
+
+ def set_group
+ if group
+ Process.egid = gid
+ Process.gid = gid
+ end
+ end
+
+ def set_environment
+ environment.each do |env_var,value|
+ ENV[env_var] = value
+ end
+ end
+
+ def set_umask
+ File.umask(umask) if umask
+ end
+
+ def set_cwd
+ Dir.chdir(cwd) if cwd
+ end
+
+ def initialize_ipc
+ @stdout_pipe, @stderr_pipe, @process_status_pipe = IO.pipe, IO.pipe, IO.pipe
+ @process_status_pipe.last.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
+ end
+
+ def child_stdout
+ @stdout_pipe[0]
+ end
+
+ def child_stderr
+ @stderr_pipe[0]
+ end
+
+ def child_process_status
+ @process_status_pipe[0]
+ end
+
+ def close_all_pipes
+ child_stdout.close unless child_stdout.closed?
+ child_stderr.close unless child_stderr.closed?
+ child_process_status.close unless child_process_status.closed?
+ end
+
+ # replace stdout, and stderr with pipes to the parent, and close the
+ # reader side of the error marshaling side channel. Close STDIN so when we
+ # exec, the new program will know it's never getting input ever.
+ def configure_subprocess_file_descriptors
+ process_status_pipe.first.close
+
+ # HACK: for some reason, just STDIN.close isn't good enough when running
+ # under ruby 1.9.2, so make it good enough:
+ stdin_reader, stdin_writer = IO.pipe
+ stdin_writer.close
+ STDIN.reopen stdin_reader
+ stdin_reader.close
+
+ stdout_pipe.first.close
+ STDOUT.reopen stdout_pipe.last
+ stdout_pipe.last.close
+
+ stderr_pipe.first.close
+ STDERR.reopen stderr_pipe.last
+ stderr_pipe.last.close
+
+ STDOUT.sync = STDERR.sync = true
+ end
+
+ def configure_parent_process_file_descriptors
+ # Close the sides of the pipes we don't care about
+ stdout_pipe.last.close
+ stderr_pipe.last.close
+ process_status_pipe.last.close
+ # Get output as it happens rather than buffered
+ child_stdout.sync = true
+ child_stderr.sync = true
+
+ true
+ end
+
+ # Some patch levels of ruby in wide use (in particular the ruby 1.8.6 on OSX)
+ # segfault when you IO.select a pipe that's reached eof. Weak sauce.
+ def open_pipes
+ @open_pipes ||= [child_stdout, child_stderr]
+ end
+
+ def read_stdout_to_buffer
+ while chunk = child_stdout.read_nonblock(READ_SIZE)
+ @stdout << chunk
+ @live_stream << chunk if @live_stream
+ end
+ rescue Errno::EAGAIN
+ rescue EOFError
+ open_pipes.delete_at(0)
+ end
+
+ def read_stderr_to_buffer
+ while chunk = child_stderr.read_nonblock(READ_SIZE)
+ @stderr << chunk
+ end
+ rescue Errno::EAGAIN
+ rescue EOFError
+ open_pipes.delete_at(1)
+ end
+
+ def fork_subprocess
+ initialize_ipc
+
+ fork do
+ configure_subprocess_file_descriptors
+
+ set_group
+ set_user
+ set_environment
+ set_umask
+ set_cwd
+
+ begin
+ command.kind_of?(Array) ? exec(*command) : exec(command)
+
+ raise 'forty-two' # Should never get here
+ rescue Exception => e
+ Marshal.dump(e, process_status_pipe.last)
+ process_status_pipe.last.flush
+ end
+ process_status_pipe.last.close unless (process_status_pipe.last.closed?)
+ exit!
+ end
+ end
+
+ # Attempt to get a Marshaled error from the side-channel.
+ # If it's there, un-marshal it and raise. If it's not there,
+ # assume everything went well.
+ def propagate_pre_exec_failure
+ begin
+ e = Marshal.load child_process_status
+ raise(Exception === e ? e : "unknown failure: #{e.inspect}")
+ rescue EOFError # If we get an EOF error, then the exec was successful
+ true
+ ensure
+ child_process_status.close
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/mixlib/shellout/version.rb b/lib/mixlib/shellout/version.rb
new file mode 100644
index 0000000..1834e03
--- /dev/null
+++ b/lib/mixlib/shellout/version.rb
@@ -0,0 +1,5 @@
+module Mixlib
+ class Shellout
+ VERSION = "1.0.0.rc.0"
+ end
+end
diff --git a/lib/mixlib/shellout/windows.rb b/lib/mixlib/shellout/windows.rb
new file mode 100644
index 0000000..7c79446
--- /dev/null
+++ b/lib/mixlib/shellout/windows.rb
@@ -0,0 +1,554 @@
+#--
+# Author:: Daniel DeLeo (<dan@opscode.com>)
+# Author:: John Keiser (<jkeiser@opscode.com>)
+# Copyright:: Copyright (c) 2011 Opscode, Inc.
+# License:: Apache License, Version 2.0
+#
+# 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 'win32/process'
+require 'windows/handle'
+require 'windows/process'
+require 'windows/synchronize'
+
+module Mixlib
+ class ShellOut
+ module Windows
+
+ include ::Windows::Handle
+ include ::Windows::Process
+ include ::Windows::Synchronize
+
+ TIME_SLICE = 0.05
+
+ #--
+ # Missing lots of features from the UNIX version, such as
+ # uid, etc.
+ def run_command
+
+ #
+ # Create pipes to capture stdout and stderr,
+ #
+ stdout_read, stdout_write = IO.pipe
+ stderr_read, stderr_write = IO.pipe
+ open_streams = [ stdout_read, stderr_read ]
+
+ begin
+
+ #
+ # Set cwd, environment, appname, etc.
+ #
+ app_name, command_line = command_to_run
+ create_process_args = {
+ :app_name => app_name,
+ :command_line => command_line,
+ :startup_info => {
+ :stdout => stdout_write,
+ :stderr => stderr_write
+ },
+ :environment => inherit_environment.map { |k,v| "#{k}=#{v}" },
+ :close_handles => false
+ }
+ create_process_args[:cwd] = cwd if cwd
+
+ #
+ # Start the process
+ #
+ process = Process.create(create_process_args)
+ begin
+
+ #
+ # Wait for the process to finish, consuming output as we go
+ #
+ start_wait = Time.now
+ while true
+ wait_status = WaitForSingleObject(process.process_handle, 0)
+ case wait_status
+ when WAIT_OBJECT_0
+ # Get process exit code
+ exit_code = [0].pack('l')
+ unless GetExitCodeProcess(process.process_handle, exit_code)
+ raise get_last_error
+ end
+ @status = ThingThatLooksSortOfLikeAProcessStatus.new
+ @status.exitstatus = exit_code.unpack('l').first
+
+ return self
+ when WAIT_TIMEOUT
+ # Kill the process
+ if (Time.now - start_wait) > timeout
+ raise Chef::Exceptions::CommandTimeout, "command timed out:\n#{format_for_exception}"
+ end
+
+ consume_output(open_streams, stdout_read, stderr_read)
+ else
+ raise "Unknown response from WaitForSingleObject(#{process.process_handle}, #{timeout*1000}): #{wait_status}"
+ end
+
+ end
+
+ ensure
+ CloseHandle(process.thread_handle)
+ CloseHandle(process.process_handle)
+ end
+
+ ensure
+ #
+ # Consume all remaining data from the pipes until they are closed
+ #
+ stdout_write.close
+ stderr_write.close
+
+ while consume_output(open_streams, stdout_read, stderr_read)
+ end
+ end
+ end
+
+ private
+
+ class ThingThatLooksSortOfLikeAProcessStatus
+ attr_accessor :exitstatus
+ end
+
+ def consume_output(open_streams, stdout_read, stderr_read)
+ return false if open_streams.length == 0
+ ready = IO.select(open_streams, nil, nil, READ_WAIT_TIME)
+ return true if ! ready
+
+ if ready.first.include?(stdout_read)
+ begin
+ next_chunk = stdout_read.readpartial(READ_SIZE)
+ @stdout << next_chunk
+ @live_stream << next_chunk if @live_stream
+ rescue EOFError
+ stdout_read.close
+ open_streams.delete(stdout_read)
+ end
+ end
+
+ if ready.first.include?(stderr_read)
+ begin
+ @stderr << stderr_read.readpartial(READ_SIZE)
+ rescue EOFError
+ stderr_read.close
+ open_streams.delete(stderr_read)
+ end
+ end
+
+ return true
+ end
+
+ SHOULD_USE_CMD = /['"<>|&%]|\b(?:assoc|break|call|cd|chcp|chdir|cls|color|copy|ctty|date|del|dir|echo|endlocal|erase|exit|for|ftype|goto|if|lfnfor|lh|lock|md|mkdir|move|path|pause|popd|prompt|pushd|rd|rem|ren|rename|rmdir|set|setlocal|shift|start|time|title|truename|type|unlock|ver|verify|vol)\b/
+
+ def command_to_run
+ if command =~ SHOULD_USE_CMD
+ [ ENV['COMSPEC'], "cmd /c #{command}" ]
+ else
+ [ which(command[0,command.index(/\s/) || command.length]), command ]
+ end
+ end
+
+ def inherit_environment
+ result = {}
+ ENV.each_pair do |k,v|
+ result[k] = v
+ end
+
+ environment.each_pair do |k,v|
+ if v == nil
+ result.delete(k)
+ else
+ result[k] = v
+ end
+ end
+ result
+ end
+
+ def which(cmd)
+ return cmd if File.executable? cmd
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') + [''] : ['']
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
+ exts.each { |ext|
+ exe = "#{path}/#{cmd}#{ext}"
+ return exe if File.executable? exe
+ }
+ end
+ return nil
+ end
+ end # class
+ end
+end
+
+#
+# Override module Windows::Process.CreateProcess to fix bug when
+# using both app_name and command_line
+#
+module Windows
+ module Process
+ API.new('CreateProcess', 'SPPPLLLPPP', 'B')
+ end
+end
+
+#
+# Override Win32::Process.create to take a proper environment hash
+# so that variables can contain semicolons
+# (submitted patch to owner)
+#
+module Process
+ def create(args)
+ unless args.kind_of?(Hash)
+ raise TypeError, 'Expecting hash-style keyword arguments'
+ end
+
+ valid_keys = %w/
+ app_name command_line inherit creation_flags cwd environment
+ startup_info thread_inherit process_inherit close_handles with_logon
+ domain password
+ /
+
+ valid_si_keys = %/
+ startf_flags desktop title x y x_size y_size x_count_chars
+ y_count_chars fill_attribute sw_flags stdin stdout stderr
+ /
+
+ # Set default values
+ hash = {
+ 'app_name' => nil,
+ 'creation_flags' => 0,
+ 'close_handles' => true
+ }
+
+ # Validate the keys, and convert symbols and case to lowercase strings.
+ args.each{ |key, val|
+ key = key.to_s.downcase
+ unless valid_keys.include?(key)
+ raise ArgumentError, "invalid key '#{key}'"
+ end
+ hash[key] = val
+ }
+
+ si_hash = {}
+
+ # If the startup_info key is present, validate its subkeys
+ if hash['startup_info']
+ hash['startup_info'].each{ |key, val|
+ key = key.to_s.downcase
+ unless valid_si_keys.include?(key)
+ raise ArgumentError, "invalid startup_info key '#{key}'"
+ end
+ si_hash[key] = val
+ }
+ end
+
+ # The +command_line+ key is mandatory unless the +app_name+ key
+ # is specified.
+ unless hash['command_line']
+ if hash['app_name']
+ hash['command_line'] = hash['app_name']
+ hash['app_name'] = nil
+ else
+ raise ArgumentError, 'command_line or app_name must be specified'
+ end
+ end
+
+ # The environment string should be passed as an array of A=B paths, or
+ # as a string of ';' separated paths.
+ if hash['environment']
+ env = hash['environment']
+ if !env.respond_to?(:join)
+ # Backwards compat for ; separated paths
+ env = hash['environment'].split(File::PATH_SEPARATOR)
+ end
+ # The argument format is a series of null-terminated strings, with an additional null terminator.
+ env = env.map { |e| e + "\0" }.join("") + "\0"
+ if hash['with_logon']
+ env = env.multi_to_wide(e)
+ end
+ env = [env].pack('p*').unpack('L').first
+ else
+ env = nil
+ end
+
+ startinfo = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
+ startinfo = startinfo.pack('LLLLLLLLLLLLSSLLLL')
+ procinfo = [0,0,0,0].pack('LLLL')
+
+ # Process SECURITY_ATTRIBUTE structure
+ process_security = 0
+ if hash['process_inherit']
+ process_security = [0,0,0].pack('LLL')
+ process_security[0,4] = [12].pack('L') # sizeof(SECURITY_ATTRIBUTE)
+ process_security[8,4] = [1].pack('L') # TRUE
+ end
+
+ # Thread SECURITY_ATTRIBUTE structure
+ thread_security = 0
+ if hash['thread_inherit']
+ thread_security = [0,0,0].pack('LLL')
+ thread_security[0,4] = [12].pack('L') # sizeof(SECURITY_ATTRIBUTE)
+ thread_security[8,4] = [1].pack('L') # TRUE
+ end
+
+ # Automatically handle stdin, stdout and stderr as either IO objects
+ # or file descriptors. This won't work for StringIO, however.
+ ['stdin', 'stdout', 'stderr'].each{ |io|
+ if si_hash[io]
+ if si_hash[io].respond_to?(:fileno)
+ handle = get_osfhandle(si_hash[io].fileno)
+ else
+ handle = get_osfhandle(si_hash[io])
+ end
+
+ if handle == INVALID_HANDLE_VALUE
+ raise Error, get_last_error
+ end
+
+ # Most implementations of Ruby on Windows create inheritable
+ # handles by default, but some do not. RF bug #26988.
+ bool = SetHandleInformation(
+ handle,
+ HANDLE_FLAG_INHERIT,
+ HANDLE_FLAG_INHERIT
+ )
+
+ raise Error, get_last_error unless bool
+
+ si_hash[io] = handle
+ si_hash['startf_flags'] ||= 0
+ si_hash['startf_flags'] |= STARTF_USESTDHANDLES
+ hash['inherit'] = true
+ end
+ }
+
+ # The bytes not covered here are reserved (null)
+ unless si_hash.empty?
+ startinfo[0,4] = [startinfo.size].pack('L')
+ startinfo[8,4] = [si_hash['desktop']].pack('p*') if si_hash['desktop']
+ startinfo[12,4] = [si_hash['title']].pack('p*') if si_hash['title']
+ startinfo[16,4] = [si_hash['x']].pack('L') if si_hash['x']
+ startinfo[20,4] = [si_hash['y']].pack('L') if si_hash['y']
+ startinfo[24,4] = [si_hash['x_size']].pack('L') if si_hash['x_size']
+ startinfo[28,4] = [si_hash['y_size']].pack('L') if si_hash['y_size']
+ startinfo[32,4] = [si_hash['x_count_chars']].pack('L') if si_hash['x_count_chars']
+ startinfo[36,4] = [si_hash['y_count_chars']].pack('L') if si_hash['y_count_chars']
+ startinfo[40,4] = [si_hash['fill_attribute']].pack('L') if si_hash['fill_attribute']
+ startinfo[44,4] = [si_hash['startf_flags']].pack('L') if si_hash['startf_flags']
+ startinfo[48,2] = [si_hash['sw_flags']].pack('S') if si_hash['sw_flags']
+ startinfo[56,4] = [si_hash['stdin']].pack('L') if si_hash['stdin']
+ startinfo[60,4] = [si_hash['stdout']].pack('L') if si_hash['stdout']
+ startinfo[64,4] = [si_hash['stderr']].pack('L') if si_hash['stderr']
+ end
+
+ if hash['with_logon']
+ logon = multi_to_wide(hash['with_logon'])
+ domain = multi_to_wide(hash['domain'])
+ app = hash['app_name'].nil? ? nil : multi_to_wide(hash['app_name'])
+ cmd = hash['command_line'].nil? ? nil : multi_to_wide(hash['command_line'])
+ cwd = multi_to_wide(hash['cwd'])
+ passwd = multi_to_wide(hash['password'])
+
+ hash['creation_flags'] |= CREATE_UNICODE_ENVIRONMENT
+
+ process_ran = CreateProcessWithLogonW(
+ logon, # User
+ domain, # Domain
+ passwd, # Password
+ LOGON_WITH_PROFILE, # Logon flags
+ app, # App name
+ cmd, # Command line
+ hash['creation_flags'], # Creation flags
+ env, # Environment
+ cwd, # Working directory
+ startinfo, # Startup Info
+ procinfo # Process Info
+ )
+ else
+ process_ran = CreateProcess(
+ hash['app_name'], # App name
+ hash['command_line'], # Command line
+ process_security, # Process attributes
+ thread_security, # Thread attributes
+ hash['inherit'], # Inherit handles?
+ hash['creation_flags'], # Creation flags
+ env, # Environment
+ hash['cwd'], # Working directory
+ startinfo, # Startup Info
+ procinfo # Process Info
+ )
+ end
+
+ # TODO: Close stdin, stdout and stderr handles in the si_hash unless
+ # they're pointing to one of the standard handles already. [Maybe]
+ if !process_ran
+ raise_last_error("CreateProcess()")
+ end
+
+ # Automatically close the process and thread handles in the
+ # PROCESS_INFORMATION struct unless explicitly told not to.
+ if hash['close_handles']
+ CloseHandle(procinfo[0,4].unpack('L').first)
+ CloseHandle(procinfo[4,4].unpack('L').first)
+ end
+
+ ProcessInfo.new(
+ procinfo[0,4].unpack('L').first, # hProcess
+ procinfo[4,4].unpack('L').first, # hThread
+ procinfo[8,4].unpack('L').first, # hProcessId
+ procinfo[12,4].unpack('L').first # hThreadId
+ )
+ end
+
+ def self.raise_last_error(operation)
+ error_string = "#{operation} failed: #{get_last_error}"
+ last_error_code = GetLastError()
+ if ERROR_CODE_MAP.has_key?(last_error_code)
+ raise ERROR_CODE_MAP[last_error_code], error_string
+ else
+ raise Error, error_string
+ end
+ end
+
+ # List from ruby/win32/win32.c
+ ERROR_CODE_MAP = {
+ ERROR_INVALID_FUNCTION => Errno::EINVAL,
+ ERROR_FILE_NOT_FOUND => Errno::ENOENT,
+ ERROR_PATH_NOT_FOUND => Errno::ENOENT,
+ ERROR_TOO_MANY_OPEN_FILES => Errno::EMFILE,
+ ERROR_ACCESS_DENIED => Errno::EACCES,
+ ERROR_INVALID_HANDLE => Errno::EBADF,
+ ERROR_ARENA_TRASHED => Errno::ENOMEM,
+ ERROR_NOT_ENOUGH_MEMORY => Errno::ENOMEM,
+ ERROR_INVALID_BLOCK => Errno::ENOMEM,
+ ERROR_BAD_ENVIRONMENT => Errno::E2BIG,
+ ERROR_BAD_FORMAT => Errno::ENOEXEC,
+ ERROR_INVALID_ACCESS => Errno::EINVAL,
+ ERROR_INVALID_DATA => Errno::EINVAL,
+ ERROR_INVALID_DRIVE => Errno::ENOENT,
+ ERROR_CURRENT_DIRECTORY => Errno::EACCES,
+ ERROR_NOT_SAME_DEVICE => Errno::EXDEV,
+ ERROR_NO_MORE_FILES => Errno::ENOENT,
+ ERROR_WRITE_PROTECT => Errno::EROFS,
+ ERROR_BAD_UNIT => Errno::ENODEV,
+ ERROR_NOT_READY => Errno::ENXIO,
+ ERROR_BAD_COMMAND => Errno::EACCES,
+ ERROR_CRC => Errno::EACCES,
+ ERROR_BAD_LENGTH => Errno::EACCES,
+ ERROR_SEEK => Errno::EIO,
+ ERROR_NOT_DOS_DISK => Errno::EACCES,
+ ERROR_SECTOR_NOT_FOUND => Errno::EACCES,
+ ERROR_OUT_OF_PAPER => Errno::EACCES,
+ ERROR_WRITE_FAULT => Errno::EIO,
+ ERROR_READ_FAULT => Errno::EIO,
+ ERROR_GEN_FAILURE => Errno::EACCES,
+ ERROR_LOCK_VIOLATION => Errno::EACCES,
+ ERROR_SHARING_VIOLATION => Errno::EACCES,
+ ERROR_WRONG_DISK => Errno::EACCES,
+ ERROR_SHARING_BUFFER_EXCEEDED => Errno::EACCES,
+# ERROR_BAD_NETPATH => Errno::ENOENT,
+# ERROR_NETWORK_ACCESS_DENIED => Errno::EACCES,
+# ERROR_BAD_NET_NAME => Errno::ENOENT,
+ ERROR_FILE_EXISTS => Errno::EEXIST,
+ ERROR_CANNOT_MAKE => Errno::EACCES,
+ ERROR_FAIL_I24 => Errno::EACCES,
+ ERROR_INVALID_PARAMETER => Errno::EINVAL,
+ ERROR_NO_PROC_SLOTS => Errno::EAGAIN,
+ ERROR_DRIVE_LOCKED => Errno::EACCES,
+ ERROR_BROKEN_PIPE => Errno::EPIPE,
+ ERROR_DISK_FULL => Errno::ENOSPC,
+ ERROR_INVALID_TARGET_HANDLE => Errno::EBADF,
+ ERROR_INVALID_HANDLE => Errno::EINVAL,
+ ERROR_WAIT_NO_CHILDREN => Errno::ECHILD,
+ ERROR_CHILD_NOT_COMPLETE => Errno::ECHILD,
+ ERROR_DIRECT_ACCESS_HANDLE => Errno::EBADF,
+ ERROR_NEGATIVE_SEEK => Errno::EINVAL,
+ ERROR_SEEK_ON_DEVICE => Errno::EACCES,
+ ERROR_DIR_NOT_EMPTY => Errno::ENOTEMPTY,
+# ERROR_DIRECTORY => Errno::ENOTDIR,
+ ERROR_NOT_LOCKED => Errno::EACCES,
+ ERROR_BAD_PATHNAME => Errno::ENOENT,
+ ERROR_MAX_THRDS_REACHED => Errno::EAGAIN,
+# ERROR_LOCK_FAILED => Errno::EACCES,
+ ERROR_ALREADY_EXISTS => Errno::EEXIST,
+ ERROR_INVALID_STARTING_CODESEG => Errno::ENOEXEC,
+ ERROR_INVALID_STACKSEG => Errno::ENOEXEC,
+ ERROR_INVALID_MODULETYPE => Errno::ENOEXEC,
+ ERROR_INVALID_EXE_SIGNATURE => Errno::ENOEXEC,
+ ERROR_EXE_MARKED_INVALID => Errno::ENOEXEC,
+ ERROR_BAD_EXE_FORMAT => Errno::ENOEXEC,
+ ERROR_ITERATED_DATA_EXCEEDS_64k => Errno::ENOEXEC,
+ ERROR_INVALID_MINALLOCSIZE => Errno::ENOEXEC,
+ ERROR_DYNLINK_FROM_INVALID_RING => Errno::ENOEXEC,
+ ERROR_IOPL_NOT_ENABLED => Errno::ENOEXEC,
+ ERROR_INVALID_SEGDPL => Errno::ENOEXEC,
+ ERROR_AUTODATASEG_EXCEEDS_64k => Errno::ENOEXEC,
+ ERROR_RING2SEG_MUST_BE_MOVABLE => Errno::ENOEXEC,
+ ERROR_RELOC_CHAIN_XEEDS_SEGLIM => Errno::ENOEXEC,
+ ERROR_INFLOOP_IN_RELOC_CHAIN => Errno::ENOEXEC,
+ ERROR_FILENAME_EXCED_RANGE => Errno::ENOENT,
+ ERROR_NESTING_NOT_ALLOWED => Errno::EAGAIN,
+# ERROR_PIPE_LOCAL => Errno::EPIPE,
+ ERROR_BAD_PIPE => Errno::EPIPE,
+ ERROR_PIPE_BUSY => Errno::EAGAIN,
+ ERROR_NO_DATA => Errno::EPIPE,
+ ERROR_PIPE_NOT_CONNECTED => Errno::EPIPE,
+ ERROR_OPERATION_ABORTED => Errno::EINTR,
+# ERROR_NOT_ENOUGH_QUOTA => Errno::ENOMEM,
+ ERROR_MOD_NOT_FOUND => Errno::ENOENT,
+ WSAEINTR => Errno::EINTR,
+ WSAEBADF => Errno::EBADF,
+# WSAEACCES => Errno::EACCES,
+ WSAEFAULT => Errno::EFAULT,
+ WSAEINVAL => Errno::EINVAL,
+ WSAEMFILE => Errno::EMFILE,
+ WSAEWOULDBLOCK => Errno::EWOULDBLOCK,
+ WSAEINPROGRESS => Errno::EINPROGRESS,
+ WSAEALREADY => Errno::EALREADY,
+ WSAENOTSOCK => Errno::ENOTSOCK,
+ WSAEDESTADDRREQ => Errno::EDESTADDRREQ,
+ WSAEMSGSIZE => Errno::EMSGSIZE,
+ WSAEPROTOTYPE => Errno::EPROTOTYPE,
+ WSAENOPROTOOPT => Errno::ENOPROTOOPT,
+ WSAEPROTONOSUPPORT => Errno::EPROTONOSUPPORT,
+ WSAESOCKTNOSUPPORT => Errno::ESOCKTNOSUPPORT,
+ WSAEOPNOTSUPP => Errno::EOPNOTSUPP,
+ WSAEPFNOSUPPORT => Errno::EPFNOSUPPORT,
+ WSAEAFNOSUPPORT => Errno::EAFNOSUPPORT,
+ WSAEADDRINUSE => Errno::EADDRINUSE,
+ WSAEADDRNOTAVAIL => Errno::EADDRNOTAVAIL,
+ WSAENETDOWN => Errno::ENETDOWN,
+ WSAENETUNREACH => Errno::ENETUNREACH,
+ WSAENETRESET => Errno::ENETRESET,
+ WSAECONNABORTED => Errno::ECONNABORTED,
+ WSAECONNRESET => Errno::ECONNRESET,
+ WSAENOBUFS => Errno::ENOBUFS,
+ WSAEISCONN => Errno::EISCONN,
+ WSAENOTCONN => Errno::ENOTCONN,
+ WSAESHUTDOWN => Errno::ESHUTDOWN,
+ WSAETOOMANYREFS => Errno::ETOOMANYREFS,
+# WSAETIMEDOUT => Errno::ETIMEDOUT,
+ WSAECONNREFUSED => Errno::ECONNREFUSED,
+ WSAELOOP => Errno::ELOOP,
+ WSAENAMETOOLONG => Errno::ENAMETOOLONG,
+ WSAEHOSTDOWN => Errno::EHOSTDOWN,
+ WSAEHOSTUNREACH => Errno::EHOSTUNREACH,
+# WSAEPROCLIM => Errno::EPROCLIM,
+# WSAENOTEMPTY => Errno::ENOTEMPTY,
+ WSAEUSERS => Errno::EUSERS,
+ WSAEDQUOT => Errno::EDQUOT,
+ WSAESTALE => Errno::ESTALE,
+ WSAEREMOTE => Errno::EREMOTE
+ }
+
+ module_function :create
+end